XAML or HTML

029. 툴팁 말풍선 슬라이더(ToolTip Balloon Slider) #3

XAML 뽀개기

이전 포스트들에서 여러가지 방법으로 (Thumb) 말풍선 툴팁(ToolTip) 따라다니는 슬라이더(Slider) 만들어 보았습니다. 그런데 뭔가 만족스럽지 않았습니다. 그래서  깔끔한 방법이 없을까 한참을 고민하고 태스트하고 찾아보던 끝에 하나의 포스트를 이어가기로 했습니다.

 

예제에 사용된 대부분의 코드는 동일하니 달라진 점만 일부 설명합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 툴팁 팝업 -->
<Popup x:Name="PART_ToolTipPopup"
       PlacementTarget="{Binding ElementName=Thumb}"
       Placement="Center" VerticalOffset="-30"
       AllowsTransparency="True">
    <StackPanel>
        <Border CornerRadius="5" Background="SkyBlue">
            <TextBlock Text="{Binding Value, ElementName=PART_Track, StringFormat=N0}"
                       Foreground="Black" HorizontalAlignment="Center" Margin="10,5" />
        </Border>
        <ed:RegularPolygon Fill="SkyBlue" Margin="0,-8,0,0" Panel.ZIndex="-1"                                               
                           PointCount="4" Width="10" Height="15" />
    </StackPanel>
</Popup>
cs

XAML 코드에서 사용된 Popup 그대로 사용됩니다. 추가로 이전 예제에서 불필요하게 사용되었던 VerticalOffset 프로퍼티는 -30으로 고정 시켰습니다. 팝업의 세로 위치는 변하지 않으므로 미리 고정되어도 같습니다.

 

1
2
3
4
5
6
7
8
9
<!-- New Behavior -->
<Slider Value="5" Minimum="0" Maximum="100" IsSnapToTickEnabled="True"
        TickFrequency="1" TickPlacement="Both"
        VerticalAlignment="Center" Margin="10,0"
        Style="{DynamicResource Style_Slider}">
    <i:Interaction.Behaviors>
        <bhv:ToolTipToPopupSliderBehavior />
    </i:Interaction.Behaviors>
</Slider>
cs

 

슬라이더의 XAML 코드입니다. 달라진 점은 IsSnapToTickEnabled 프로퍼티를 True 활성화하여 Tick 스냅이 걸리도록 점입니다. 썸이 이동할 1 단위로 스냅된다는 시나리오를 추가했습니다. TickFrequency, TickPlacement 등의 프로퍼티는 Tick 디자인 화면에 노출되도록 하는 프로퍼티들입니다.

 

아래부터는 새로운 방식으로 만든 비헤이비어(Behavior)입니다.

 

ToolTipToPopupSliderBehavior.cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void AssociatedObject_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
    _popup.HorizontalOffset = 0;
    _popup.IsOpen = true;
 
    var xMoveOffset = AssociatedObject.ActualWidth / (AssociatedObject.Maximum - AssociatedObject.Minimum);
    if (e.NewValue > e.OldValue && e.NewValue != AssociatedObject.Maximum)
    {
        _popup.HorizontalOffset += xMoveOffset;
    }
    else if (e.NewValue < e.OldValue && e.OldValue != AssociatedObject.Maximum)
    {
        _popup.HorizontalOffset -= xMoveOffset;            
    }
}
cs

이번 새로운 비헤이비어에서는 마우스와 키보드 이벤트를 각각 따로 이용하지 않고 동시에 적용되는 ValueChanged 이벤트를 이용했습니다. 이전에는 각각에 해당하는 이벤트를 핸들링했기 때문에 조금 중복되는 느낌을 받았는데 공통으로 발생하는 ValueChanged 이벤트를 이용함으로서  깔끔해진 느낌이 나는 합니다.

 

그리고 이번에는 슬라이더의 MinimumMaximum 프로퍼티의 값과 실제 ActualWidth의 크기를 이용해 썸이 움직였을때 팝업이 움직여야하는 HorizontalOffset 값을 매번 계산하여 초기화하고 다시 넣어주도록 하였습니다슬라이더의 크기가 고정이 아니라 윈도우 리사이징  어떤 이유로 변경이 가능하다는 가정된 시나리오 아래에 그렇게 되도록 했습니다.

 

그리고 기존에 Key.Left와 Key.Right의 판단은 OldValue와 NewValue를 비교하는 것으로 대체했습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#region Event handler < Mouse
 
private void _thumb_MouseLeave(object sender, MouseEventArgs e)
{
    _popup.IsOpen = false;
}
 
#endregion
 
#region Event handler < Keyboard
                
private void Slider_PreviewKeyUp(object sender, KeyEventArgs e)
{
    _popup.IsOpen = false;
}
 
#endregion
cs

 

팝업이 닫히는 동작의 경우는 이전 방법 그대로 각각의 이벤트 핸들러를 이용하도록 했습니다.



제가 사용했던 여러 방식들이 정답이 아니거나 옳지 못할 수도 있습니다. 하지만 나름대로는 몇일, 몇시간을 투자해서 얻은 결론이라 흥미로웠습니다. 나중에라도 나은 방법을 알게 된다면 기쁜 마음으로 다시 공유하게 같습니다.

 

마지막으로 아래 링크는 이번 예제에서는 직접적으로 사용되진 않았지만 예제 프로젝트 안에 일부 주석과 FollowingPopup.cs 파일 등에 남겨 두었습니다. 키보드도 함께 지원해야 한다는 저의 시나리오에 부합되지 않아 사용하지 않았습니다만 괜찮은 참고자료인 같아 남겨둡니다. 간단한 내용이지만 다른 시선으로 눈을 뜨게 해준 stackoverflow에서 찾은 질문과 답변입니다. 저에게는 QueryCursor라는 이벤트를 이렇게도 사용할 있구나 하는 새로운 발견이었습니다.

 

참고 : Make Tooltip of WPF slider stay on screen while dragging


025. 커스텀 트리거 & 액션(Custom Trigger & Action) #2

XAML 뽀개기

이전 포스트에서 기본 XAML 코드와 기본 CS 코드를 준비했습니다.



간단한 시계가 완성되었습니다. 이제부터 커스텀 트리거와 커스텀 액션을 정의해봅시다.


TimeChangedTrigger < TimeToAlarmBehavior.cs


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class TimeChangedTrigger : TriggerBase<TextBlock>
{
    public string SpecialTime
    {
        get { return (string)GetValue(SpecialTimeProperty); }
        set { SetValue(SpecialTimeProperty, value); }
    }
 
    public static readonly DependencyProperty SpecialTimeProperty =
        DependencyProperty.Register(
            "SpecialTime"
            typeof(string), 
            typeof(TimeChangedTrigger), 
            new PropertyMetadata(string.Empty));
 
    protected override void OnAttached()
    {
        base.OnAttached();
 
        AssociatedObject.Loaded += AssociatedObject_Loaded;
    }
 
    protected override void OnDetaching()
    {
        AssociatedObject.Loaded -= AssociatedObject_Loaded;
 
        base.OnDetaching();
    }
 
    private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
    {
        var textBlock = AssociatedObject as TextBlock;
        if (textBlock == nullreturn;
 
        var dpd = DependencyPropertyDescriptor.FromProperty(TextBlock.TextProperty, typeof(TextBlock));
        if (dpd == nullreturn;
        dpd.AddValueChanged(textBlock, OnTextChanged);
    }
 
    private void OnTextChanged(object sender, EventArgs e)
    {
        var textBlock = sender as TextBlock;
        if (textBlock == nullreturn;
 
        if (textBlock.Text == SpecialTime)
        {
            InvokeActions("ALARM");
        }
        else
        {
            InvokeActions("");
        }
    }
}
 
cs


위 코드는 TimeToAlarmBehavior.cs 비헤이비어 전체 코드입니다. 부분으로 나누어 살펴보겠습니다.


1
2
3
4
5
6
public class TimeChangedTrigger : TriggerBase<TextBlock>
{
 
// 생략
 
}
cs


TimeChangedTrigger 커스텀 트리거는 TriggerBase를 상속 받아서 정의합니다. 그리고 TextBlock 요소에만 적용하도록 TextBlock 사용할 요소로 지정했습니다


1
2
3
4
5
6
7
8
9
10
11
12
public string SpecialTime
{
    get { return (string)GetValue(SpecialTimeProperty); }
    set { SetValue(SpecialTimeProperty, value); }
}
 
public static readonly DependencyProperty SpecialTimeProperty =
    DependencyProperty.Register(
        "SpecialTime"
        typeof(string), 
        typeof(TimeChangedTrigger), 
        new PropertyMetadata(string.Empty));
cs


지정 시간을 받기 위해 SpecialTime 의존 속성을 추가합니다. 의존 속성에 대해서는 이전 포스트 참고합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
protected override void OnAttached()
{
    base.OnAttached();
    
    AssociatedObject.Loaded += AssociatedObject_Loaded;
}
 
protected override void OnDetaching()
{
    AssociatedObject.Loaded -= AssociatedObject_Loaded;
 
    base.OnDetaching();
}
cs

 

OnAttached와 OnDetaching 메소드를 재정의합니다. Behavior에서 메소드를 재정의하는 일은 필수라 있습니다. AssociatedObject를 통해 XAML 코드의 UI 요소를 가져옵니다. OnAttached 메소드에서는 가져온 요소에 Loaded 이벤트를 연결하고 OnDetaching 메소드에서는 Loaded 이벤트를 연결 해제합니다.

 

1
2
3
4
5
6
7
8
9
private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
{
    var textBlock = AssociatedObject as TextBlock;
    if (textBlock == nullreturn;
 
    var dpd = DependencyPropertyDescriptor.FromProperty(TextBlock.TextProperty, typeof(TextBlock));
    if (dpd == nullreturn;
    dpd.AddValueChanged(textBlock, OnTextChanged);
}
cs

 

AssociatedObject_Loaded 이벤트 핸들러에서는 TextBlock OnTextChanged 이벤트를 추가합니다. 기본 TextBlock TextChanged 이벤트가 없습니다. 하지만 간단한 방법을 찾아 적용했습니다. 참고한 링크는 아래입니다.

 

참고 : stackoverflow > How to detect a change in the Text property of a TextBlock?

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void OnTextChanged(object sender, EventArgs e)
{
    var textBlock = sender as TextBlock;
    if (textBlock == nullreturn;
 
    if (textBlock.Text == SpecialTime)
    {    
        InvokeActions("ALARM");            
    }
    else
    {
        InvokeActions("");
    }
}
cs

 

OnTextChanged 이벤트 핸들러에서는 InvokeActions을 호출할 조건을 정의해 주었습니다. 사용자가 지정한 시간과 동일한 시간이 되었을 커스텀 액션을 호출하도록 하는 간단한 조건문입니다. InvokeActions 메소드는 커스텀 액션을 호출하기 위해 반드시 필요한 주요 메소드입니다. 파라미터를 이용해 "ALARM"이라는 문자열을 전달하도록 했습니다.

 

ChangeForegroundAction < TimeToAlarmBehavior.cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ChangeForegroundAction : TriggerAction<TextBlock>
{
    public Brush Foreground
    {
        get { return (Brush)GetValue(ForegroundProperty); }
        set { SetValue(ForegroundProperty, value); }
    }
 
    public static readonly DependencyProperty ForegroundProperty =
        DependencyProperty.Register(
            "Foreground"
            typeof(Brush), 
            typeof(ChangeForegroundAction), 
            new PropertyMetadata(Brushes.Black));
 
    protected override void Invoke(object parameter)
    {
        var textBlock = AssociatedObject as TextBlock;
        if (textBlock == null || parameter == nullreturn;
 
        var param = parameter.ToString().ToUpper();
        if (param == "ALARM")
        {
            textBlock.Foreground = Foreground;
        }
        else 
        {
            textBlock.Foreground = Brushes.Black;
        }
    }
}
cs

 

ChangeForegroundAction 커스텀 액션은 TriggerAction을 상속 받습니다. 그리고 TextBlock 요소에 적용 가능하게 하기 위해 TextBlock 사용할 요소로 지정했습니다. 부분으로 나누어 살펴보겠습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
public Brush Foreground
{
    get { return (Brush)GetValue(ForegroundProperty); }
    set { SetValue(ForegroundProperty, value); }
}
 
public static readonly DependencyProperty ForegroundProperty =
    DependencyProperty.Register(
        "Foreground"
        typeof(Brush), 
        typeof(ChangeForegroundAction), 
        new PropertyMetadata(Brushes.Black));
cs

 

알람에 사용될 색상을 받기 위해 Foreground 의존 속성을 추가합니다. 의존 속성에 대해서는 이전 포스트 참고합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected override void Invoke(object parameter)
{
    var textBlock = AssociatedObject as TextBlock;
    if (textBlock == null || parameter == nullreturn;
 
    var param = parameter.ToString().ToUpper();
    if (param == "ALARM")
    {
        textBlock.Foreground = Foreground;
    }
    else 
    {
        textBlock.Foreground = Brushes.Black;
    }
}
cs

 

Invoke 메소드 재정의에서는 파라미터 값을 확인해 AssociatedObject를 통해 들어온 TextBlock Foreground 속성을 사용자가 지정한 색상으로 변경합니다.

 

 

적용하기 이전 TimeToAlarmBehavior 비헤이비어 코드가 막 준비된 빌드를 했다면 Blend for VS Assets(자산) 윈도우에서 보이게 됩니다. 이전 비헤이비어 포스트에서 설명했습니다만 드래그  드롭 동작을 이용해 디자인 뷰에서 패널에 적용하는 것이 훨씬 간편합니다. 네임스페이스 정의도 함께 이루어지므로 매우 편리합니다.


SDK에서 제공하는 ControlStoryboardAction 기본 비헤이비어도 함께 적용합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 초 -->
<TextBlock x:Name="txtSecond" Text="0" Grid.Column="2" RenderTransformOrigin="0.5,1">
    <TextBlock.RenderTransform>
        <TransformGroup>
            <ScaleTransform/>
            <SkewTransform/>
            <RotateTransform/>
            <TranslateTransform/>
        </TransformGroup>
    </TextBlock.RenderTransform>
    <i:Interaction.Triggers>
        <bhv:TimeChangedTrigger SpecialTime="7">
            <ei:ControlStoryboardAction Storyboard="{StaticResource StoryboardSecond}"/>
            <bhv:ChangeForegroundAction Foreground="Blue"/>
        </bhv:TimeChangedTrigger>
    </i:Interaction.Triggers>
</TextBlock>
cs

 

TimeChangedTrigger 커스텀 트리거에는 SpecialTime 속성을 원하는 시간대로 지정합니다.

 

 

ControlStoryboardAction에서는 시간이 변경될 때 실행할 StoryboardSecond 스토리보드를 지정합니다.

 

 

추가한 ChangeForegroundAction 커스텀 액션에서는 SpecialTime과 일치했을 때 변경될 Foreground 색상을 지정합니다.

 

 

실행해서 올바르게 동작하는지 확인해 봅니다.

 

몇가지 기능에 대해 추가로 설명합니다. 액션은 하나의 트리거에 여러 개를 동시에 사용할 있습니다. 여기서는 ControlStoryboardAction과 ChangeForegroundAction 동시에 사용했습니다. 그리고 ChangeForegroundAction 기본으로 제공되는 ChangePropertyAction 이용해서도 동일한 효과를 있습니다.

 

참고 : MSDN > Creating custom triggers and actions

 

샘플 코드 : https://github.com/CharlesKwon/XamlSimplified

 

관련 목차

 

023. 사용자 추가 비헤이비어(Custom Behavior)

024. 사용자 추가 비헤이비어(Custom Trigger & Action) #1

025사용자 추가 비헤이비어(Custom Trigger & Action) #2 

023. 커스텀 비헤이비어(Custom Behavior)

XAML 뽀개기

이전 포스트에서는 기본으로 제공되는 비헤이비어 사용법을 살펴보았습니다. 이번 포스트에서는 사용자가 직접 비헤이비어를 만들어보는 방법을 계속해서 살펴보겠습니다.

 

 

먼저 예제를 위한 상황 설정에 대한 설명입니다. 위 이미지처럼 Expander 컨트롤을 여러 동시에 사용하는 작은 인터페이스에서는 컨텐츠 노출을 효율적으로 하기 위해 한번에 하나씩만 열리도록 제약을 줄 때가 종종 있습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<StackPanel Margin="5">
    <TextBlock Text="Only one can expand at a time" FontWeight="Bold" Margin="0,0,0,5"/>
            
    <Expander Header="Header 01" IsExpanded="True">
        <TextBlock Text="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book." 
                   TextWrapping="Wrap"/>
    </Expander>
    <Expander Header="Header 02">
        <TextBlock Text="It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged." 
                   TextWrapping="Wrap"/>
    </Expander>
    <Expander Header="Header 03">
        <TextBlock Text="It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum." 
                   TextWrapping="Wrap"/>
    </Expander>
</StackPanel>
cs

기본 XAML 코드 : MainWindow.xaml


XAML 코드는 최대한 간단히 StackPanel 이용해 여러 개의 Expander 컨트롤을 하나의 그룹으로 만들었습니다.

 

OnlyOneExpanderBehavior.cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class OnlyOneExpanderBehavior : Behavior<Panel>
{
    protected override void OnAttached()
    {
        base.OnAttached();
 
        AssociatedObject.Loaded += AssociatedObject_Loaded;
    }
 
    protected override void OnDetaching()
    {            
        AssociatedObject.Loaded -= AssociatedObject_Loaded;
        
        base.OnDetaching();
    }
        
    private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
    {
        foreach (var child in AssociatedObject.Children)
        {
            if (child == nullreturn;
            var allExpander = child as Expander;
            if (allExpander != null)
            {
                allExpander.Expanded += Expander_Expanded;
            }
        }
    }
 
    private void Expander_Expanded(object sender, RoutedEventArgs e)
    {
        foreach (var child in AssociatedObject.Children)
        {
            if (child == nullreturn;
            var allExpander = child as Expander;
            if (allExpander != null)
            {
                allExpander.IsExpanded = false;
            }
        }
 
        var expander = sender as Expander;
        if (expander == nullreturn;
        expander.Expanded -= Expander_Expanded;
        expander.IsExpanded = true;
        expander.Expanded += Expander_Expanded;
    }
}
 
cs

 

코드는 상황을 해결하는데 사용하기 위해 추가한 OnlyOneExpanderBehavior 비헤이비어 전체 코드입니다. 부분으로 나누어 살펴보겠습니다.

 

1
2
3
4
5
6
public class OnlyOneExpanderBehavior : Behavior<Panel>
{
    
// 생략
 
}
cs

 

OnlyOneExpanderBehavior는 Behavior를 상속받습니다. StackPanel 뿐만 아니라 Grid, WrapPanel Panel 상속받아 자식을 가질 있는 모든 패널 요소에 적용 가능하게 하기 위해 Panel 사용할 요소로 지정했습니다. 예제에서는 StackPanel 사용할 요소로 제한해도 상관없습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
protected override void OnAttached()
{
    base.OnAttached();
 
    AssociatedObject.Loaded += AssociatedObject_Loaded;
}
 
protected override void OnDetaching()
{
    AssociatedObject.Loaded -= AssociatedObject_Loaded;
 
    base.OnDetaching();
}
cs

 

OnAttached와 OnDetaching 메소드를 재정의합니다. Behavior를 작성할 때  메소드를 재정의하는 일은 필수라 있습니다. AssociatedObject를 통해 XAML 코드에 적용한 UI 요소를 가져옵니다. OnAttached 메소드에서는 가져온 요소에 Loaded 이벤트를 연결하고 OnDetaching 메소드에서는 Loaded 이벤트를 연결 해제합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
{
    foreach (var child in AssociatedObject.Children)
    {
        if (child == nullreturn;
        var allExpander = child as Expander;
        if (allExpander != null)
        {
            allExpander.Expanded += Expander_Expanded;
        }
    }
}
cs

 

AssociatedObject_Loaded 이벤트 핸들러에서는 패널의 하위 자식들 모든 Expander 컨트롤을 찾아서 Expanded 이벤트를 연결합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void Expander_Expanded(object sender, RoutedEventArgs e)
{
    foreach (var child in AssociatedObject.Children)
    {
        if (child == nullreturn;
        var allExpander = child as Expander;
        if (allExpander != null)
        {
            allExpander.IsExpanded = false;
        }
    }
 
    var expander = sender as Expander;            
    if (expander == nullreturn;
    expander.Expanded -= Expander_Expanded;
    expander.IsExpanded = true;
    expander.Expanded += Expander_Expanded;
}
cs

 

Expander_Expanded 이벤트 핸들러에서는 모든 Expander 컨트롤을 IsExpanded 속성을 이용해 닫은 sender 통해 들어온 Expander 컨트롤만 다시 열어줍니다. 그냥 열어주게되면 이벤트 핸들러를 반복해서 타게 되므로 열어주기 전과 후에 이벤트 핸들러 해제 연결을 다시 한번 해줍니다.

 

1
2
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" 
xmlns:bhv="clr-namespace:Sample.Behavior"
cs

 

1
2
3
4
5
6
7
8
<StackPanel Margin="5">
    <i:Interaction.Behaviors>
        <bhv:OnlyOneExpanderBehavior/>
    </i:Interaction.Behaviors>
 
// 생략
 
</StackPanel>
cs

 

XAML 코드에 완성된 비헤이비어를 적용합니다. 추가한 비헤이비어를 사용하기 위해서는 두개의 네임스페이스가 우선 정의되어 있어야 합니다.

 

 

적용하기 이전 OnlyOneExpanderBehavior 비헤이비어 코드가 막 준비된 빌드를 했다면 Blend for VS Assets(자산) 윈도우에서 보이게 됩니다. 이전 비헤이비어 포스트에서 설명했습니다만 드래그  드롭 동작을 이용해 디자인 뷰에서 패널에 적용하는 것이 훨씬 간편합니다. 네임스페이스 정의도 함께 이루어지므로 매우 편리합니다.

 

 

실행해서 올바르게 동작하는지 확인해 봅니다.

 

샘플 코드 : https://github.com/CharlesKwon/XamlSimplified

 

관련 목차

 

023. 사용자 추가 비헤이비어(Custom Behavior)

024. 사용자 추가 비헤이비어(Custom Trigger & Action) #1

025. 사용자 추가 비헤이비어(Custom Trigger & Action) #2 



024. 커스텀 트리거 & 액션(Custom Trigger & Action) #1

XAML 뽀개기

이전 포스트에서는 OnlyOneExpanderBehavior 비헤이비어 단독으로 사용되었지만 이전 포스트에서 살펴본 CallMethodAction 비헤이비어인 경우 Trigger(트리거) Action(액션)으로 구성되어 있던 것을 있었습니다. 이번 포스트에서는 Custom Trigger Custom Action 대해 알아 봅시다.

 

 

이번에 사용될 예제는 간단한 알람 시계입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<Grid>
    <Grid.Resources>
        <Style TargetType="{x:Type TextBlock}">
            <Setter Property="FontSize" Value="100"/>
            <Setter Property="HorizontalAlignment" Value="Center"/>
        </Style>
    </Grid.Resources>
 
    <Grid.ColumnDefinitions>
        <ColumnDefinition/>
        <ColumnDefinition/>
        <ColumnDefinition/>
    </Grid.ColumnDefinitions>
 
    <!-- 시 -->
    <TextBlock x:Name="txtHour" Text="0"/>
 
    <!-- 분 -->
    <TextBlock x:Name="txtMinute" Text="0" Grid.Column="1"/>
 
    <!-- 초 -->
    <TextBlock x:Name="txtSecond" Text="0" Grid.Column="2"/>
 
    <!-- Separation points -->
    <TextBlock Text=":" HorizontalAlignment="Right" Margin="0,0,-10,0"/>
    <TextBlock Text=":" HorizontalAlignment="Right" Margin="0,0,-10,0" Grid.Column="1"/>
</Grid>
cs

 

기본 XAML 코드 : MainWindow.xaml

 

예제를 위한 XAML 코드입니다. , , 초를 TextBlock으로 나누어 표현합니다. CS 코드에서 접근하기 윈해 x:Name 각각 선언했습니다. 다른 특이점은 없습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
 
        var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
        timer.Tick += Timer_Tick;
        timer.Start();
    }
 
    private void Timer_Tick(object sender, EventArgs e)
    {
        if (DateTime.Now.Hour > 12)
        {
         txtHour.Text = (DateTime.Now.Hour - 12).ToString();
        }
        else
        {
            txtHour.Text = DateTime.Now.Hour.ToString();
        }
 
        txtMinute.Text = DateTime.Now.Minute.ToString();            
        txtSecond.Text = DateTime.Now.Second.ToString();
    }
}
cs

 

기본 CS 코드 : MainWindow.xaml.cs

 

예제를 위한 CS 코드입니다. XAML 코드에 선언된 각 TextBlock 현재 시간을 1 단위로 업데이트합니다. 12단위 시간을 표현하기 위한 코드를 제외하고 다른 특이점은 없습니다.

 

, , 모두 동일한 방법이 사용될 것이므로 이제부터는 단위만 분리해서 살펴보겠습니다. 예제 시나리오는 시간이 변경될 지정한 Storyboard 실행하고, 알람이 울릴 시간(트리거) 설정할 있으며 해당 시간이 되었을 색상을 변경(액션)하는 시나리오입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Storyboard x:Key="StoryboardSecond">
    <DoubleAnimationUsingKeyFrames 
        Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleY)" 
        Storyboard.TargetName="txtSecond">
        <EasingDoubleKeyFrame KeyTime="0:0:0.4" Value="1.1">
            <EasingDoubleKeyFrame.EasingFunction>
                <SineEase EasingMode="EaseIn"/>
                    </EasingDoubleKeyFrame.EasingFunction>
                </EasingDoubleKeyFrame>
            <EasingDoubleKeyFrame KeyTime="0:0:1" Value="1">
            <EasingDoubleKeyFrame.EasingFunction>
                <SineEase EasingMode="EaseOut"/>
            </EasingDoubleKeyFrame.EasingFunction>
        </EasingDoubleKeyFrame>
    </DoubleAnimationUsingKeyFrames>
</Storyboard>
cs

 

1
2
3
4
5
6
7
8
9
10
11
<!-- 초 -->
<TextBlock x:Name="txtSecond" Text="0" Grid.Column="2" RenderTransformOrigin="0.5,1">
    <TextBlock.RenderTransform>
        <TransformGroup>
            <ScaleTransform/>
            <SkewTransform/>
            <RotateTransform/>
            <TranslateTransform/>
        </TransformGroup>
    </TextBlock.RenderTransform>
</TextBlock>
cs

 

StoryboardHour, StoryboardMinute, StoryboardSecond 스토리보드 추가

 

스토리보드는 리소스에 정의해야 합니다시간이 가고 있다는 느낌을 주기 위해 ScaleY축에 약간의 움직임을 반복하도록 했습니다 모두 각각 분리해서 작성합니다.


Blend for VS 이용하면 쉽게 작성할 있습니다. RenderTransform 속성을 이용한 스토리보드를 작성하기 위해서는 각 TextBlock에 RenderTransformOrigin과 RenderTransform 그룹이 미리 정의되어 있어야 하는 것 주의가 필요 합니다. Blend for VS 이용해 스토리보드를 작성한다면 자동으로 추가되므로 걱정할 필요가 없습니다

 

다음 포스트에서는 Custom Trigger Custom Action 추가해봅시다.

 

참고 : MSDN > Creating custom triggers and actions

 

샘플 코드 : https://github.com/CharlesKwon/XamlSimplified

 

관련 목차

 

023. 사용자 추가 비헤이비어(Custom Behavior)

024. 사용자 추가 비헤이비어(Custom Trigger & Action) #1

025사용자 추가 비헤이비어(Custom Trigger & Action) #2 


022. 비헤이비어(CallMethodAction) #3

XAML 뽀개기

이전 포스트에서 살펴본 방법은 원하는 기능을 구현하는데 문제가 없었습니다. 하지만 기능을 필요로 하는 인터페이스를 마주 때마다 반복해야하는 비효율적인 문제가 예상되었습니다. 사실 문제를 해결하는 좋은 방법은 예 포스트들에서 이미 살펴보았습니다. 바로 Style(스타일), Template(템플릿) 관련 포스팅에서 말입니다.


관련 목차

 

018. 컨트롤템플릿(ControlTemplate) #1

019. 컨트롤템플릿(ControlTemplate) #2

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<Window.Resources>
    <!-- ScrollViewer 기본 템플릿 -->
    <ControlTemplate x:Key="Template_ScrollViewer" TargetType="{x:Type ScrollViewer}">
        <Grid x:Name="Grid" Background="{TemplateBinding Background}">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="*"/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
 
            <Rectangle x:Name="Corner" Grid.Column="1" Fill="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" Grid.Row="1"/>
            <ScrollContentPresenter x:Name="PART_ScrollContentPresenter" CanContentScroll="{TemplateBinding CanContentScroll}" CanHorizontallyScroll="False" CanVerticallyScroll="False" ContentTemplate="{TemplateBinding ContentTemplate}" Content="{TemplateBinding Content}" Grid.Column="0" Margin="{TemplateBinding Padding}" Grid.Row="0"/>
            <ScrollBar x:Name="PART_VerticalScrollBar" AutomationProperties.AutomationId="VerticalScrollBar" Cursor="Arrow" Grid.Column="1" Maximum="{TemplateBinding ScrollableHeight}" Minimum="0" Grid.Row="0" Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}" Value="{Binding VerticalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}" ViewportSize="{TemplateBinding ViewportHeight}"/>
            <ScrollBar x:Name="PART_HorizontalScrollBar" AutomationProperties.AutomationId="HorizontalScrollBar" Cursor="Arrow" Grid.Column="0" Maximum="{TemplateBinding ScrollableWidth}" Minimum="0" Orientation="Horizontal" Grid.Row="1" Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}" Value="{Binding HorizontalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}" ViewportSize="{TemplateBinding ViewportWidth}"/>
 
            <!-- 바로가기 버튼 -->
            <Button x:Name="PART_ScrollToHome"
                    Content="↑" FontWeight="Bold" FontSize="18"
                    HorizontalAlignment="Right" VerticalAlignment="Bottom"
                    Margin="5">
                <i:Interaction.Triggers>
                    <i:EventTrigger EventName="Click">
                        <ei:CallMethodAction MethodName="ScrollToHome"/>
                    </i:EventTrigger>
                </i:Interaction.Triggers>
            </Button>
        </Grid>
    </ControlTemplate>
</Window.Resources>
 
cs


ScrollViewer의 기본 템플릿을 구하는 방법은 예전 포스트 참고합니다. 조금 복잡해 보이지만 이번에 하고자 하는 기능 구현에만 집중합니다.

 

이전 포스트에서 ScrollViewer와 함께 Grid 묶여 있던 Button ScrollViewer의 기본 템플릿 안으로 옮기면 어떨까요? 만약 그렇게 한다면 템플릿은 리소스로 정의하기 때문에 해당 템플릿 리소스를 사용하는 ScrollViewer라면 해당 요소 기능을 자동으로 가지게 됩니다.

 

1
2
3
4
5
6
7
8
9
10
11
<Button x:Name="PART_ScrollToHome"
        Content="↑" FontWeight="Bold" FontSize="18"
        HorizontalAlignment="Right" VerticalAlignment="Bottom"
        Margin="5">
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click">
            <ei:CallMethodAction TargetObject="{Binding  RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ScrollViewer}}}" 
                                 MethodName="ScrollToHome"/>
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>
cs

 

여기서 주의할 점은 Button의 위치가 ScrollViewer 컨트롤의 밖에서 ScrollViewer의 템플릿 안으로 변경되었기 때문에 TargetObject 속성을 제대로 찾을 없게 된다는 점입니다.

 

1
TargetObject="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ScrollViewer}}}"
cs

 

첫번째AncestorType(원형이 되는 타입) 지정해 찾는 방법으로 간단히 바꿔 있습니다.

 

1
TargetObject="{Binding RelativeSource={RelativeSource TemplatedParent}}"
cs

 

두번째템플릿 부모를 찾는 방법도 있습니다. 예전 포스트에서도 다룬 적이 있으니 참고하세요.

 

1
2
3
4
5
6
7
8
9
10
11
<!-- ScrollViewer 템플릿 -->
<ScrollViewer Grid.Column="1" 
              Template="{DynamicResource Template_ScrollViewer}">
    <StackPanel Margin="5">
        <TextBlock Text="A"/>
                
        .....
 
        <TextBlock Text="Z"/>
    </StackPanel>
</ScrollViewer>
cs

 

 

앞에서 완료한 ScrollViewer의 템플릿을 ScrollViewer에 적용한 태스트해봅니다.

 

그런데 목록형 컨텐츠를 표현할 ScrollViewer만 단독으로 사용하는 경우는 드뭅니다. ItemsSource 속성, DataTemplate, 아이템을 선택했을 때 상태변경  여러 이유로 ListBox 자주 사용합니다. ListView, DataGrid 컨트롤 등도 마찬가지 입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- ListBox 기본 스타일 -->
<Style x:Key="Style_ListBox" TargetType="{x:Type ListBox}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type ListBox}">
                <Border x:Name="Bd" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" Padding="1" SnapsToDevicePixels="true">
                    <ScrollViewer Focusable="false" Padding="{TemplateBinding Padding}" 
                                  Template="{DynamicResource Template_ScrollViewer}">
                        <ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                    </ScrollViewer>
                </Border>                    
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>
 
cs

 

ListBox 기본 템플릿을 예전 포스트 참고해 스타일과 함께 리소스에 정의하고 이번에 살펴보고 있는 기능 구현에만 집중하기 위해 다른 Setter Trigger 정의들은 제거했습니다.


ListBox 기본 템플릿은 ScrollViewer를 포함하고 습니다. 이전과 동일한 방법으로 ScrollViewer 템플릿을 ScrollViewer의 템플릿으로 정의합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- ListBox 스타일-->
<ListBox Grid.Column="2" 
         Style="{DynamicResource Style_ListBox}">
    <sys:Char>A</sys:Char>
    <sys:Char>B</sys:Char>
    <sys:Char>C</sys:Char>
    <sys:Char>D</sys:Char>
    <sys:Char>E</sys:Char>
    <sys:Char>F</sys:Char>
    <sys:Char>G</sys:Char>
    <sys:Char>H</sys:Char>
    <sys:Char>I</sys:Char>
    <sys:Char>J</sys:Char>
    <sys:Char>K</sys:Char>
    <sys:Char>L</sys:Char>
    <sys:Char>M</sys:Char>
    <sys:Char>N</sys:Char>
    <sys:Char>O</sys:Char>
    <sys:Char>P</sys:Char>
    <sys:Char>Q</sys:Char>
    <sys:Char>R</sys:Char>
    <sys:Char>S</sys:Char>
    <sys:Char>T</sys:Char>
    <sys:Char>U</sys:Char>
    <sys:Char>V</sys:Char>
    <sys:Char>W</sys:Char>
    <sys:Char>X</sys:Char>
    <sys:Char>Y</sys:Char>
    <sys:Char>Z</sys:Char>
</ListBox>
cs

 

그런 다음 ListBox의 스타일에 정의만해주면 끝입니다

 


실행해서 태스트해봅니다. 더 많은 기본 비헤이비어에 대해서는 아래 MSDN 링크를 따라 참고하세요.


샘플 코드 : https://github.com/CharlesKwon/XamlSimplified


참고 : MSDN > Working with built-in behaviors


관련 목차

 

020. 비헤이비어(Behavior) #1

021. 비헤이비어(CallMethodAction) #2

022. 비헤이비어(CallMethodAction) #3


021. 비헤이비어(CallMethodAction) #2

XAML 뽀개기

사용자에게 좋은 인터페이스 경험을 제공하기 위해 계속해서 새로운 형태의 인터페이스가 유행 또는 제안되고 있는 같습니다. 중에서 가지를 CallMethodAction 비헤이비어를 이용해 구현해 봅시다비헤이비어 이름 그대로 메소드를 호출하는 기능을 하는 비헤이비어입니다.

 


예제로 사용할 것은 내용(Contents) 아주 길 때 흔히 제공되 인터페이스입니다. 스크롤을 하는 도중 다시 처음으로 돌아가려면 많은 스크롤 양에 불편함을 느낄 때가 종종 있습니다. 그럴 사용하면 알맞은 인터페이스입니다. 처음으로 바로가는 버튼을 제공하 아주 간단한 기능입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<Grid>
    <ScrollViewer x:Name="Target_ScrollViewer">
        <StackPanel Margin="5">
            <TextBlock Text="A"/>
            <TextBlock Text="B"/>
            <TextBlock Text="C"/>
            <TextBlock Text="D"/>
            <TextBlock Text="E"/>
            <TextBlock Text="F"/>
            <TextBlock Text="G"/>
            <TextBlock Text="H"/>
            <TextBlock Text="I"/>
            <TextBlock Text="J"/>
            <TextBlock Text="K"/>
            <TextBlock Text="L"/>
            <TextBlock Text="M"/>
            <TextBlock Text="N"/>
            <TextBlock Text="O"/>
            <TextBlock Text="P"/>
            <TextBlock Text="Q"/>
            <TextBlock Text="R"/>
            <TextBlock Text="S"/>
            <TextBlock Text="T"/>
            <TextBlock Text="U"/>
            <TextBlock Text="V"/>
            <TextBlock Text="W"/>
            <TextBlock Text="X"/>
            <TextBlock Text="Y"/>
            <TextBlock Text="Z"/>
        </StackPanel>
    </ScrollViewer>
 
    <Button Content="↑" FontWeight="Bold" FontSize="18"
            HorizontalAlignment="Right" VerticalAlignment="Bottom"
            Margin="5,5,22,5">
        <i:Interaction.Triggers>
            <i:EventTrigger EventName="Click">
                <ei:CallMethodAction TargetObject="{Binding ElementName=Target_ScrollViewer}" 
                                     MethodName="ScrollToHome"/>
            </i:EventTrigger>
        </i:Interaction.Triggers>
    </Button>
</Grid>
 
cs

 

ScrollViewer와 Button Grid 안에 구성했습니다. 그런 다음 이전 포스트에서 이야기 일반적인 방법으로 비헤이비어를 버튼에 설정합니다Grid 사용한 것은 별 다른 의미는 없고 정리하는 의미에서 그룹을 지은 것뿐 입니다

 

 

ScrollViewer 컨트롤은 스크롤과 관련된 함수(Method)를 포함하고 있습니다. 이런 정보를 바탕으로 비헤이비어에 여러 속성을 정의할 수 있습니다.

 

1
2
3
4
5
6
<i:Interaction.Triggers>
    <i:EventTrigger EventName="Click">
        <ei:CallMethodAction TargetObject="{Binding ElementName=Target_ScrollViewer}" 
                             MethodName="ScrollToHome"/>
    </i:EventTrigger>
</i:Interaction.Triggers>
cs

 


ElementName 문법을 이용해 TargetObject 속성을 정의하고 MethodName 속성에는 의도에 맞게 ScrollToHome 함수를 정의합니다. 트리거 버튼의 Click 이벤트로 되어 있어 앞서 정의한 액션이 실행되게 됩니다. 간단합니다.


이러한 비헤이비어의 속성 정의는 Blend for VS 속성 윈도우를 이용하면 XAML 문법에 익숙치 않아도 쉽게 정의할 있습니다.

 

그러면 기능을 필요로 하는 인터페이스마다 매번 버튼을 추가하고 하위에 비헤이비어를 정의해야할까요? 굉장히 비효율적일 같습니다. 다음 포스트에서 이를 해결해 봅시다.


샘플 코드 : https://github.com/CharlesKwon/XamlSimplified


참고 : MSDN > Working with built-in behaviors


관련 목차

 

020. 비헤이비어(Behavior) #1

021. 비헤이비어(CallMethodAction) #2

022. 비헤이비어(CallMethodAction) #3


020. 비헤이비어(Behavior) #1

XAML 뽀개기

Behavior 행동, 반응이란 뜻을 가지고 있습니다. 특정 상황, 조건(Trigger) 등에 따라 UI 요소에 어떤 영향을 줄  주로 사용됩니다. 사용자가 커스텀 비헤이비어를 만들어 기능을 확장할 수도 있습니다.


일단 비헤이비어를 사용하려면 System.Windows.Interactivity 어셈블리(dll) 참조가 필요합니다. 기본으로 제공되는 비헤이비어는 자주 사용되므로 Microsoft.Expression.Interactions 어셈블리도 함께 참조합니다.

 

 

Visual Studio 설치시 설정할 있는 옵션에서 SDK 제외되었다면 Blend for VS Assets(자산) 윈도우에서 기본으로 제공되는 비헤이비어가 보이지 않을 있습니다.

 

 

비헤이비어가 보이지 않는다면 Visual Studio Installer 이용해 SDK 설치합니다.

 

 

C:\Program Files (x86)\Microsoft SDKs\Expression\Blend\.NETFramework\v4.5\Libraries\System.Windows.Interactivity.dll

 

이미 설치되어 있으면 경로를 참고해 수동으로 참조시켜도 됩니다.

 

 

참조 및 빌드를 후에는 기본으로 제공되는 비헤이비어를 Blend for VS에서 손쉽게 사용할 있습니다. 일반적인 사용 방법은 디자인뷰에 사용하길 원하는 UI 컨트롤 요소에 마우스로 drag & drop(드래그 드롭)하는 것입니다. 관련 어셈블리도 자동으로 참조됩니다.

 

1
2
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
cs

       

 방법을 이용하면 XAML 코드에 네임스페이스 등록도 자동으로 이루어 집니다. 여기서 접두사는 본인 취향에 맞게 수정해도 됩니다.

 

 

1
2
3
4
5
6
7
8
9
<Button Content="↑" FontWeight="Bold" FontSize="18"
        HorizontalAlignment="Right" VerticalAlignment="Bottom"
        Margin="5,5,22,5">
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click">
            <ei:CallMethodAction/>
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>
cs

 

비헤이비어는 드래그 앤 드롭하는 순간 UI 요소 하위에 자동으로 생성됩니다. 자동으로 생성된 XAML 코드를 살펴보면 Trigger(트리거) Action(액션)으로 구성되어 있습니다.

 

 

비헤이비어를 선택한 상태에서는 속성 윈도우에서 비헤이비어의 다양한 속성을 정의할 있습니다. 정의하지 않은 트리거의 Source 속성들은 트리 구조내 부모 요소 암시적으로 따릅니다.

 

 

트리거 타입은 새로 만들기 버튼을 눌러 변경할 있습니다.


다음 포스트에서는 나름 의미있는 예제를 들어 살펴보겠습니다.


샘플 코드 : https://github.com/CharlesKwon/XamlSimplified


참고 : MSDN > Working with built-in behaviors


관련 목차

 

020. 비헤이비어(Behavior) #1

021. 비헤이비어(CallMethodAction) #2

022. 비헤이비어(CallMethodAction) #3