WPF ToggleButton Style 내부의 ControlTemplate 에서 XamlParseException 에러가 발생합니다

ToggleButton 클릭 시, Checked 상태에서는 버튼의 Background 색상과 버튼의 토글 스위치가 우측으로 이동하는 애니메이션이 추가된 ToggleButton 의 스타일을 만들고 있습니다.

EventTrigger를 사용해서 하단에 ColorAnimation 과 DoubleAnimation 을 추가하여 버튼 클릭 시 스위치가 우측으로 이동하면서 Background가 청색으로 변하고, 다시 클릭 시, 스위치가 좌측으로 이동하면서 Background가 흰색으로 변합니다.

구현 자체는 잘 되었으나, 구현한 스타일을 적용한 버튼이 존재하는 UserControl 을 벗어났다가 다시 돌아오면 해당 에러가 나타납니다.

메인 페이지 윈도우 하단에 Frame을 만들고, 미리 만들어둔 Usercontrol을 Frame의
Source에 model 변수로 바인딩해서 여러 컨트롤을 전환하는 형태로 만들었습니다.

Frame에 연결된 A라는 UserControl에 ToggleButton을 구현하고 ON으로 전환해둔 다음에, Frame에 연결된 UserControl을 다른 거로 바꾸고 다시 A로 돌아오면 저 에러가 발생합니다.

파트에서 에러가 발생하는데, 토글버튼 ControlTemplate 내부에 작성한 Border의 이름이 System.Windows.Controls.ControlTemplate 의 이름 범위 안에 없습니다. 라는 내부 예외로 나타납니다.

Frame의 Source Uri 를 model 변수로 바인딩하고 메인윈도우의 버튼으로 다른 UserControl의 Uri를 Frame Source로 할당하는 과정에서 ControlTemplate에서 Border 이름을 가져오지 못하는 것 같습니다.

왜 이런 현상이 나타나는지를 찾아보아도 잘 나오지 않아 질문을 올립니다. ㅠㅠ

혹시 EventTrigger 를 사용하려면, 바인딩을 Event하고 해야하는 걸까요?
지금은 IsChecked 속성과 바인딩을 해둔 상태입니다…

아래는 제가 작성한 Style의 코드입니다.

<Style x:Key="toggleButtonOptic" TargetType="{x:Type ToggleButton}">
    <Setter Property="Height" Value="32"/>
    <Setter Property="Width" Value="64"/>
    <Setter Property="Margin" Value="25 0 0 0"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type ToggleButton}">
                <Grid Margin="0 0 0 0" Width="64" Height="32" HorizontalAlignment="Center" VerticalAlignment="Center">
                    <Border x:Name="BorderToggle"
                        Background="{StaticResource ColorTypographySub_LineMain}"
                        CornerRadius="16"
                        Height="32" Width="64"
                        Margin="0 0 0 0" HorizontalAlignment="Center" VerticalAlignment="Center">
                        <Canvas Background="Transparent">
                            <Ellipse x:Name="EllipseToggle"
                                Height="24" Width="24"
                                HorizontalAlignment="Left"
                                VerticalAlignment="Center"
                                Canvas.Left="4"
                                Canvas.Top="4"
                                Fill="{StaticResource ColorWhite}">
                            </Ellipse>
                        </Canvas>
                    </Border>
                </Grid>
                <ControlTemplate.Triggers>
                    <!-- EventTrigger Checked -->
                    
                    <EventTrigger RoutedEvent="Checked" >
                        <BeginStoryboard>
                            <Storyboard>
                                <ColorAnimation Storyboard.TargetName="BorderToggle"
                                            Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                            From="#B5B5B5" To="#66CAB9" Duration="0:0:0.2"/>
                                <DoubleAnimation Storyboard.TargetName="EllipseToggle"
                                                 Storyboard.TargetProperty="(Canvas.Left)"
                                                 From="4" To="36" Duration="0:0:0.15"/>
                            </Storyboard>
                        </BeginStoryboard>
                    </EventTrigger>

                    <EventTrigger RoutedEvent="Unchecked">
                        <BeginStoryboard>
                            <Storyboard>
                                <ColorAnimation Storyboard.TargetName="BorderToggle"
                                            Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                            From="#66CAB9" To="#B5B5B5" Duration="0:0:0.2"/>
                                <DoubleAnimation Storyboard.TargetName="EllipseToggle"
                                            Storyboard.TargetProperty="(Canvas.Left)"
                                            From="36" To="4" Duration="0:0:0.15"/>
                            </Storyboard>
                        </BeginStoryboard>
                    </EventTrigger>
                    
                    <!--
                    <Trigger Property="IsChecked" Value="True">
                        <Setter TargetName="EllipseToggle" Property="Fill" Value="#66CAB9"/>
                    </Trigger>
                    -->
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

위 스타일을 적용한 버튼은 아래와 같이 적용했습니다.

<ToggleButton Style="{StaticResource toggleButtonOptic}" IsChecked="{Binding opticControlModel.LeftLaserShutterOn}"/>

View 와 Model은 별도의 폴더에서 관리하는 중이라, namespace가 중간에서 달라져서, 완전히 겹치지는 않습니다. View와 ViewModel은 동일한 namespace에 있고, Model은 다른 폴더에 넣어둔 상태인데, 혹시 namespace가 많이 다르면 접근이 안 되는걸까요?

Application\source\Views\MainWindowView\MainWindowView.xaml 이 메인창이고,
메인창 내부에 Frame을

<Frame x:Name="frameControlChange" NavigationUIVisibility="Hidden" Background="Transparent"
       Source="{Binding mainModel.UriControl}"
       Width="944" Height="{Binding ElementName=stckpnlControl, Path=ActualHeight}"/>

이렇게 선언하고 Source 에 바인딩한 mainModel.UriControl 의 Uri 값을 변경하는 방식으로 내부 화면을 전환하고 있습니다.

각 컨트롤의 Uri는 static Uri 형태로 작성해둔 상태인데, 생성은 UriKind.RelativeOrAbsolute 로 해두었습니다.

해당 오류는 바인딩 된 IsChecked 속성 값이 true인 상태에서 페이지가 로딩 될 때 발생하는 문제로 FramePage를 사용하는 패턴에서만 발생하는 것으로 보입니다.

정확한 이유는 알 수 없으나 Frame 클래스의 어떤 특성에 의해 컨트롤 또는 페이지를 인스터스화 해서 구성할 때, TemplateNameSpope를 등록하는 과정이 일반적인 계층 구조에서 동작하는 것과 다르기 때문으로 추측합니다.

Data Binding에 의해 IsChecked 속성 값이 true로 설정됨에 따라 Chceked 이벤트가 발생하여 Trigger가 수행 될 때 NameScope에서 BorderToggle라는 이름을 발견하지 못 한 것이죠.

대안으로는 EventTigger 대신 아래와 같이 VisualState를 사용해보시면 어떨까 합니다. WPF Control을 사용자 정의 할 때 Trigger 보다 Visual State를 사용하는 것이 다양한 컨트롤 상태를 다루는데 유리합니다.

<Style x:Key="toggleButtonOptic" TargetType="{x:Type ToggleButton}">
    <Setter Property="Height" Value="32" />
    <Setter Property="Width" Value="64" />
    <Setter Property="Margin" Value="25,0,0,0" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type ToggleButton}">
                <Grid Width="64"
                        Height="32"
                        Margin="0,0,0,0"
                        HorizontalAlignment="Center"
                        VerticalAlignment="Center">
                    <Border x:Name="BorderToggle"
                            Width="64"
                            Height="32"
                            Margin="0,0,0,0"
                            HorizontalAlignment="Center"
                            VerticalAlignment="Center"
                            Background="{StaticResource ColorTypographySub_LineMain}"
                            CornerRadius="16">
                        <Canvas Background="Transparent">
                            <Ellipse x:Name="EllipseToggle"
                                        Canvas.Left="4"
                                        Canvas.Top="4"
                                        Width="24"
                                        Height="24"
                                        HorizontalAlignment="Left"
                                        VerticalAlignment="Center"
                                        Fill="{StaticResource ColorWhite}" />
                        </Canvas>
                    </Border>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup Name="CheckStates">
                            <VisualState Name="Checked">
                                <Storyboard>
                                    <ColorAnimation Storyboard.TargetName="BorderToggle"
                                                    Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                                    From="#B5B5B5"
                                                    To="#66CAB9"
                                                    Duration="0:0:0.2" />
                                    <DoubleAnimation Storyboard.TargetName="EllipseToggle"
                                                        Storyboard.TargetProperty="(Canvas.Left)"
                                                        From="4"
                                                        To="36"
                                                        Duration="0:0:0.15" />
                                </Storyboard>
                            </VisualState>
                            <VisualState Name="Unchecked">
                                <Storyboard>
                                    <ColorAnimation Storyboard.TargetName="BorderToggle"
                                                    Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                                    From="#66CAB9"
                                                    To="#B5B5B5"
                                                    Duration="0:0:0.2" />
                                    <DoubleAnimation Storyboard.TargetName="EllipseToggle"
                                                        Storyboard.TargetProperty="(Canvas.Left)"
                                                        From="36"
                                                        To="4"
                                                        Duration="0:0:0.15" />
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                </Grid>
          </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

안녕하세요, 좋은 답변을 주셔서 정말 감사합니다. WPF 기초를 모르는 상태에서 필요할 때마다 구글 검색으로 공부하다보니 기초가 부족하여 많은 어려움이 있었는데, 덕분에 좋은 방법을 하나 배웠습니다.

실은, 답변을 받기 전에 여러 곳을 검색하다 보니 저도 나름의 해결책을 찾게 되어 여기에 같이 올려보고, 또 추가적인 조언을 구해보려고 합니다.

제가 이 문제를 더 찾아보았을 때는, ToggleButton 의 IsChecked 속성은 false 가 기본값이라 IsChecked 가 false 인 상태에서 Frame 전환을 하면 기본 값을 적용하면 되니 에러가 없었으나, true인 상태에서는 값이 달라 BorderToggle 을 참조하려고 시도하고, 알 수 없는 이유로 가져오지 못하는 에러로 보았습니다.

그리고 이 문제는 Trigger 자체보다는 EventTrigger를 사용할 때 발생하는 문제처럼 보였습니다.

저는 EventTrigger를 사용하지 않고 일반 Trigger 에 IsChecked 값에 따라 기존 Storyboard를 삭제하고 true/false 에 맞는 Storyboard를 새로 시작하도록 작성해보았습니다. 우선은 이렇게 작성하니 별다른 에러없이 Frame 전환이 되더군요.

제가 적용한 코드는 이렇습니다.

<Style x:Key="toggleButtonOptic" TargetType="{x:Type CheckBox}">
    <Setter Property="Height" Value="32"/>
    <Setter Property="Width" Value="64"/>
    <Setter Property="Foreground" Value="{StaticResource ColorWhite}"/>
    <Setter Property="Background" Value="{StaticResource ColorTypographySub_LineMain}"/>
    <Setter Property="Margin" Value="25 0 0 0"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type CheckBox}">
                <Grid Margin="0 0 0 0" Width="64" Height="32" HorizontalAlignment="Center" VerticalAlignment="Center">
                    <Border x:Name="BorderToggle"
                        Background="{TemplateBinding Background}"
                        CornerRadius="16"
                        Height="32" Width="64"
                        Margin="0 0 0 0" HorizontalAlignment="Center" VerticalAlignment="Center">
                        <Canvas Background="Transparent">
                            <Ellipse x:Name="EllipseToggle"
                                Height="24" Width="24"
                                HorizontalAlignment="Left"
                                VerticalAlignment="Center"
                                Canvas.Left="4"
                                Canvas.Top="4"
                                Fill="{TemplateBinding Foreground}">
                            </Ellipse>
                        </Canvas>
                    </Border>
                </Grid>
                <ControlTemplate.Resources>
                    <Storyboard x:Key="CheckTrue">
                        <!--
                        <ColorAnimation Storyboard.TargetName="BorderToggle" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                        From="#B5B5B5" To="#66CAB9" Duration="0:0:0.2"/>
                        -->
                        <DoubleAnimation Storyboard.TargetName="EllipseToggle" Storyboard.TargetProperty="(Canvas.Left)"
                                         From="4" To="36" Duration="0:0:0.15"/>
                    </Storyboard>
                    <Storyboard x:Key="CheckFalse">
                        <!--
                        <ColorAnimation Storyboard.TargetName="BorderToggle" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                        From="#66CAB9" To="#B5B5B5" Duration="0:0:0.2"/>
                        -->
                        <DoubleAnimation Storyboard.TargetName="EllipseToggle" Storyboard.TargetProperty="(Canvas.Left)"
                                         From="36" To="4" Duration="0:0:0.15"/>
                    </Storyboard>
                </ControlTemplate.Resources>
                <ControlTemplate.Triggers>
                    <Trigger Property="IsChecked" Value="False">
                        <Trigger.ExitActions>
                            <RemoveStoryboard BeginStoryboardName="BeginCheckFalse"/>
                            <BeginStoryboard x:Name="BeginCheckTrue" Storyboard="{StaticResource CheckTrue}"/>
                        </Trigger.ExitActions>
                    </Trigger>
                    <Trigger Property="IsChecked" Value="True">
                        <Trigger.ExitActions>
                            <RemoveStoryboard BeginStoryboardName="BeginCheckTrue"/>
                            <BeginStoryboard x:Name="BeginCheckFalse" Storyboard="{StaticResource CheckFalse}"/>
                        </Trigger.ExitActions>
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

알려주신 VisualStateManager 를 사용하는 Style 이 코드가 더 간략해지니 더 알아보기 쉽고, 에러 발생이 더 적을 것 같아, 앞으로는 VisualStateManager를 적극적으로 활용해보려고 합니다.

여기서 한 가지 추가로 질문을 드립니다.

Frame 전환 시에 에러가 없어진 것은 정말 좋습니다. 다만, Frame 전환이 되었을 때의 상태값에 따라 애니메이션이 무조건적으로 실행되는 점을 고치고 싶습니다.

좀 더 자세하게 설명하자면, IsChecked 속성이 true/false 값에 따라, Frame 전환이 될 때 무조건 IsChekd 속성에 할당된 애니메이션이 실행이 됩니다.

속성값에 변화를 준 적이 없다면, 애니메이션 실행 없이 기존의 상태를 기억했다 그대로 표현해줬으면 좋겠는데, 방법을 잘 모르겠습니다.

이정도로 세밀하게 변경하려면 Style 에 정의하는 것으로는 한계가 있을까요?

말씀하신 부분은 생성된 View를 인스턴스를 재사용하면 자연스럽게 해결되지만, Frame을 사용하는 경우나 DataTemplate을 이용해서 View 생성 할 경우에는 Style만 가지고는 해결할 수 없을 것 같습니다.

참고로 Frame은 페이지 Navigation간에 Page 인스턴스를 재사용하지 않고 새로운 객체를 생성한 뒤 기존 보기 상태의 속성 값만 복원하는 일반적이지 않은 개념을 사용하고 있어 예상치 않은 동작이 발생할 가능성이 있습니다.

덧붙여, 제 경험이 부족해서 그럴 수도 있지만, Windows 응용 프로그램 개발 실무에서는 FramePage를 잘 사용하지 않는 것으로 알고 있습니다. 그런데 최근 새로 오신 개발자 분들께서 FramePage 사용과 관련된 질문을 종종 하시던데, 아마도 입문 과정에서 공통적으로 접하시는 어떤 커리큘럼이 있는 것 같네요ㅎ

해결 방안

이 문제의 해결 방향은 아래와 같습니다.

  1. 최초 화면에 그려 질 때 표시되어야 할 상태의 속성 값을 직접 지정
  2. Storyboard의 From 값을 명시하지 않음

우선 1을 위해서는 OnApplyTemplate() 메서드를 재정의 해야 하기 때문에 클래스를 상속 받아야 합니다.

public class ToggleSwitch :  ToggleButton
{
	static ToggleSwitch()
	{
		DefaultStyleKeyProperty.OverrideMetadata(typeof(ToggleSwitch), new FrameworkPropertyMetadata(typeof(ToggleSwitch)));
	}

	public override void OnApplyTemplate()
	{
		base.OnApplyTemplate();

		if (IsChecked == true)
		{
			var ellipse = Template.FindName("EllipseToggle", this) as Ellipse;
			var border = Template.FindName("BorderToggle", this) as Border;

			ellipse.SetValue(Canvas.LeftProperty, 36.0);
			border.SetValue(Border.BackgroundProperty, new SolidColorBrush(Color.FromRgb(0x66, 0xCA, 0xB9)));
		}
	}
}

OnApplyTemplate() 함수 내부에서 IsChecked 속성이 true일 경우 속성일 지정해야 할 요소들을 FindName() 메서드로 찾아낸 뒤 초기 속성 값을 직접 지정해 줍니다.

2를 위해 StoryboardFrom 속성을 모두 없애줍니다. 단, EventTrigger를 사용할 경우 Storyboard가 먼저 활성화 되기 때문인지 예상대로 동작하지 않아 VisualState를 사용해 줍니다.

<Style x:Key="toggleButtonOptic" TargetType="{x:Type local:ToggleSwitch}">
    <Setter Property="Height" Value="32" />
    <Setter Property="Width" Value="64" />
    <Setter Property="Margin" Value="25,0,0,0" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:ToggleSwitch}">
                <Grid Width="64"
                        Height="32"
                        Margin="0,0,0,0"
                        HorizontalAlignment="Center"
                        VerticalAlignment="Center">
                    <Border x:Name="BorderToggle"
                            Width="64"
                            Height="32"
                            Margin="0,0,0,0"
                            HorizontalAlignment="Center"
                            VerticalAlignment="Center"
                            Background="#B5B5B5"
                            CornerRadius="16">
                        <Canvas Background="Transparent">
                            <Ellipse x:Name="EllipseToggle"
                                        Canvas.Left="4"
                                        Canvas.Top="4"
                                        Width="24"
                                        Height="24"
                                        HorizontalAlignment="Left"
                                        VerticalAlignment="Center"
                                        Fill="White" />
                        </Canvas>
                    </Border>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup Name="CheckStates">
                            <VisualState Name="Checked">
                                <Storyboard>
                                    <ColorAnimation Storyboard.TargetName="BorderToggle"
                                                    Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                                    To="#66CAB9"
                                                    Duration="0:0:0.2" />
                                    <DoubleAnimation Storyboard.TargetName="EllipseToggle"
                                                        Storyboard.TargetProperty="(Canvas.Left)"
                                                        To="36"
                                                        Duration="0:0:0.15" />
                                </Storyboard>
                            </VisualState>
                            <VisualState Name="Unchecked">
                                <Storyboard>
                                    <ColorAnimation Storyboard.TargetName="BorderToggle"
                                                    Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                                    To="#B5B5B5"
                                                    Duration="0:0:0.2" />
                                    <DoubleAnimation Storyboard.TargetName="EllipseToggle"
                                                        Storyboard.TargetProperty="(Canvas.Left)"
                                                        To="4"
                                                        Duration="0:0:0.15" />
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

위와 같이 하면 원하는 상태에 컨트롤이 로딩 되더라도 Storyboard 상의 From 값과 To 값이 같기 때문에 초기 상태에 애니메이션이 수행되지 않는 것처럼 보이게 됩니다.

bandicam2024-07-1713-10-19-438-ezgif.com-video-to-webp-converter

From 값을 지정하지 않을 때의 이점

Storyboard 애니메이션에 있어 From 값을 명시해 주지 않으면 상태 간 Transition이 일어나는 도중 기준 속성 값이 바뀌더라도 (연속 더블 클릭 조작 같이 짮은 시간에 Checked/Unchecked 될 때) 속성 애니메이션이 중간에 중단되지 않고 자연스럽게 이어지게 됩니다.

bandicam2024-07-1713-19-31-028-ezgif.com-video-to-webp-converter

만약 From을 지정하면 속성 값이 갑자기 애니메이션 중간 값에서 From 값으로 지정되어 아래와 같이 애니메이션이 튀는 느낌이 듭니다.

bandicam2024-07-1713-18-51-611-ezgif.com-video-to-webp-converter


제가 알고 있는 건 이 정도이고 더 좋은 방법을 알고 계신 분께서는 추가 답변 부탁드립니다ㅎ
2개의 좋아요