안녕하세요! 오랜만의 글입니다.
(글자 수 제한이 있어서 3개로 나누었습니다.)
저는 해외 출장 중입니다. 그래서 2월에 있을 닷넷 컨퍼런스에 가지 못해 넘 아쉽네요. 발표 세션 라인업들을 보니까 정말 기대가 됩니다.
짬을 내서 이번에 Vicky의 튜토리얼 영상 내용을 (상세하게) 리뷰하는 글을 공동 작성해봤습니다. 이번이 네 번째 영상인데요, 영상 하나를 만드는데 얼마나 오래 걸리고 개고생 하는지, 옆에서 실시간으로 보고 있네요. 멘탈 케어 유튜브 정말 아무나 하는게 아닌 것 같단 생각이 듭니다만,
그래도 영상을 통해 많은 분들과 소통하게 되어, 결론적으로는 아주 의미있고 재밌는 도전인 것 같습니다. 또 이런 비하인드 스토리와 근황을 궁금해 하실 분들이 계실까 하여, 짧게나마 이야기 드렸습니다.
그리고 이번 내용은 WPF를 시작하는 분들에게 특별히 권하는 마음을 담아 작성했으니, 도움이 되었으면 좋겠습니다. 튜토리얼 영상과 글의 전반적인 내용은 크로스플랫폼인 AvaloniaUI, Uno Platform, 그리고 OpenSilver의 기술/설계와도 꽤나 밀접한 관계가 있습니다. WPF에 능숙하신 분들도 한번 흥미를 가지고 살펴보는 것도 좋을 것 같습니다.
그럼 본문과 튜토리얼 영상 재미있게 봐주세요.
WPF 슬라이더 컨트롤의 세부 메커니즘 분석 및 Riot 스타일의 커스터마이징 (Analyzing and Customizing the Detailed Mechanisms of WPF Slider Control)
WPF에서 Template을 포함하는 Button 그리고 ToggleButton 등과 같은 기본적인 컨트롤들은 구조적으로나 논리적으로도 매우 간단하게 설계되어 있으며, (별도의 Code behind 처리 없이도) XAML만으로도 충분히 구현 가능한 단순하고 심플한 컨트롤입니다. 한편, 이보다 좀 더 구조화 되어있는 TextBox 그리고 ComboBox, Slider와 같은 컨트롤들을 살펴보면, (XAML 뿐만 아니라) C# 코드를 통한 복잡한 처리를 반드시 필요로 합니다.
따라서 단순하지 않고 복합적인 컨트롤을 구현할 때 WPF 컨트롤의 세부 구성을 잘 이해하고 있고, 그것을 응용할 수 있다면 더욱 더 우아하고 유연한 CustomControl 설계 및 개발이 가능해집니다. 따라서 이처럼 컨트롤의 근본적인 구성 요소들을 잘 다룰 수 있다면, 자연스레 MVVM 개발 패턴에서의 부족한 부분들을 새로운 시각에서 보완이 가능하고 완성도 높은 WPF 애플리케이션 구현의 길로 이어지게 됩니다.
특히, 이번에는 WPF에서 제공하는 컨트롤인 Slider를 통해 WPF가 컨트롤을 어떻게 설계하고 있는지 내부 메커니즘을 깊이 있게 살펴보고, 이를 세부적으로 분석해볼 것입니다. 물론 WPF의 모든 컨트롤 내부 사정을 일일이 살펴보는 것은 불가능에 가까우며, 그 방대한 소스코드에 파뭍힐 필요는 없습니다.
왜냐하면 WPF의 모든 소스코드가 GitHub 레포지터리를 통해 오픈소스로 공개 및 관리되고 있기 때문입니다. 따라서 우리는 필요에 따라 언제든지 GitHub 레포지터리에 접근해서 해당 컨트롤을 찾아 분석하는 것이 가능하기 때문에 더 이상 급할 필요가 없습니다. 더 피곤해지긴 했어도, 불평할 수 없죠.
이 뿐만 아니라, 앞으로도 Slider 컨트롤 이상으로 더 복잡하고 다양한 컨트롤들을 해부하고 분석할 예정입니다. 우리의 GitHub 레포지터리와 CodeProject, 그리고 YouTube와 BiliBili에서 제공하는 튜토리얼 영상까지도 많은 응원과 관심, 그리고 지지를 부탁드립니다.
Contents
-
WPF Tutorial Series
-
Specification
-
애플리케이션 프로젝트 생성
-
Slider 주요 기능 분석
-
원본 스타일 추출 과정
-
추출된 소스코드 분석
-
Code behind 확인 (GitHub 오픈소스)
-
크로스플랫폼에서의 OnApplyTemplate
-
Slider 분석을 마치며
-
Riot 스타일의 Slider (CustomControl) 컨트롤 만들기
-
프로젝트 생성 및 시작 준비
-
TextBlock (Hi Slider)
-
참조 추가 및 실행 테스트
-
Riot Slider 크기 설정
-
PART_Track
-
슬라이더 바 추가
-
슬라이더 바와 Track 간의 오차 간격 맞추기
-
PART_SelectionRange
-
Riot 스타일의 디자인 요소 추가
-
Riot 스타일의 Thumb 구현하기
-
Thumb 리소스 선언
-
RiotSlider 템플릿 전체 완성 (마무리)
-
마지막 남기는 말
1. WPF Tutorial Series
현재까지 4개의 튜토리얼 (Vicky) 시리즈가 YouTube와 BiliBili를 통해 공개되었습니다. 이 튜토리얼 영상들은 영어와 중국어로도 제공되며, 유튜브에서는 한글 자막과 함께 지원됩니다. 잘 다듬어진 소스코드와 상세하고 전문적인 설명을 통해 WPF의 깊이를 더욱 끌어올릴 수 있는 기회가 되길 바랍니다.
-
Theme Switch: Youtube, BiliBili, CodeProject, GitHub
-
Riot PlayButton: Youtube, BiliBili, CodeProject, GitHub
-
Magic Navigation Bar: Youtube, BiliBili, CodeProject, GitHub
-
Riot Slider: Youtube BiliBili, CodeProject, GitHub
2. Specification
WPF플랫폼에 익숙치 않은 경우, 환경이 혼란스러울 수도 있습니다. 이 프로젝트는 닷넷 8.0 기반이지만 WPF를 사용하기 때문에 프레임워크 타겟이 Windows 단독으로 한정되며, 따라서 윈도우 환경에서만 개발 및 실행이 가능합니다. 그리고 IDE는 비주얼스튜디오 또는 JetBrains 회사에서 제공하는 Rider 중 하나를 선택하면 됩니다. 단 비주얼스튜디오의 경우에는 VS2022 버전부터 닷넷 8.0 사용이 가능합니다. 따라서 VS2022 이하의 버전에서는 자신의 IDE 버전에 맞게 닷넷 버전을 마이그레이션 하여 사용하시기 바랍니다. 또한 마이그레이션이 학습에 큰 영향을 주진 않습니다.
- OS: Microsoft Windows 11
- IDE: Microsoft Visual Studio 2022
- Version: C# / NET 8.0 / WPF / windows target only
- NuGet: Jamesnet.Wpf
운영체제는 최신 윈도우 버전을 사용하는 것을 권장합니다. 다만 Avalonia UI, Uno Platform, MAUI 등으로의 플랫폼 확장을 고려할 경우 서브 디바이스로써 충분히 MacOS 고려하는 것도 좋습니다. 저희 또한 Thinkpad/Macbook을 사용하고 있습니다. 단 MacOS 또는 리눅스 기반에서는 비주얼스튜디오 사용이 불가능하므로 Rider가 유일한 대안이라는 점을 알아두시기를 바랍니다. vscode
3. 애플리케이션 프로젝트 생성
이 모든 시작을 위해 WPF Application 프로젝트를 생성이 먼저 필요합니다.
-
프로젝트 타입: WPF Application
-
프로젝트 이름: DemoApp
-
프로젝트 버전: 닷넷 8.0
4. Slider 주요 기능 분석
WPF Slider 컨트롤은 Button과 같은 단순 컨트롤과는 달리 아주 다양한 속성들이 존재합니다. 특히 이 속성들을 컨트롤의 기능적인 중요한 역할을 담당하기 때문에 관심 있게 살펴볼 필요가 있으며, 그 중에서도 특별하게 동작하는 주요 속성들은 다음과 같습니다.
Orientation:
WPF에서 제공되는 컨트롤은 기본적으로 범용적인 성격을 가지고 있는 경우가 종종 있습니다. 이번 Slider 컨트롤에서도 마찬가지로, Orientation 속성이 바로 그 예입니다. 이 속성을 통해 가로/세로 방향을 지정할 수 있습니다.
Orientation 속성은 StackPanel 컨트롤에서도 찾아볼 수 있습니다. StackPanel은 Orientation의 기본 값이 Vertical이지만 Slider의 Orientation 기본 값은 Horizontal입니다. 따라서 Sldier를 보통 Horizontal 형식으로 사용하는 것이 일반적이기 때문에 대부분 Orientation 기능을 알지 못했을 수도 있을 것입니다.
이번에는 Orientation의 이해를 돕기위해 의도적으로 간소화한 Slider 일부분을 살펴보겠습니다.
<Style TargetType="{x:Type Slider}">
<Setter Property="Template" Value="{StaticResource SliderHorizontal}"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Template" Value="{StaticResource SliderVertical}"/>
</Trigger>
</Style.Triggers>
</Style>
Orientation 속성을 기준으로 트리거에서 (ControlTemplate) 템플릿이 스위칭 되는 것을 볼 수 있습니다. 따라서 실제 이 컨트롤의 세부 구성이 어떻게 되어있는지를 한번 쯤 살펴본다면, Orientation 속성이 꽤나 중요한 역할을 하고 있다는 것을 단번에 쉽게 이해할 수 있습니다.
재밌는 부분입니다. 원본을 보기전까지는 Orientation을 통해 템플릿을 스위칭하는 상상이나 응용을 할 수 있었을까요? 오픈소스는 이렇게 다양한 영감을 주기도 합니다. 그리고 이 소스코드를 통해 Template을 스위칭하는 최적의 타이밍이 바로 "Style.Trigger"라는 사실도 체크합시다.
이번 튜토리얼 영상에서는 Horizontal 방향만 구현할 예정이기 때문에 Orientation을 통한 분기 스위칭 작업은 하지 구현하지 않습니다. 그렇지만 여러분은 Vertical 방향도 한번 만들어 보고, Fork를 통해 Pull Request 요청을 해보세요. 미션입니다.
그럼 Hrizontal/Vertical 속성이 각각 적용된 모습도 한번 살펴볼까요?
- Orientation: Horizontal
아래서 다룰 SelectionRage (파랑) 영역도 보이네요.
- Orientation: Vertical
이처럼 (ControlTemplate) 템플릿 자체를 스위칭하는 비슷한 컨트롤을 더 찾아보면 꽤나 존재합니다. (ScrollViewer 등)
Minimum, Maximum 그리고 Value:
이들은 각각 최소범위/최대범위 그리고 값을 나타내는 역할을 하는 double 타입의 속성들입니다. 내부적으로는 이 값들에 의해 컨트롤의 크기와 비율에 비례하여 Range와 Value 값의 위치가 자동으로 계산됩니다.
그리고 이 속성들이 모두 DependencyProperty로 되어 있기 때문에, 바인딩을 통해 동적인 상호작용도 가능합니다. 예를 들어 MVVM 구조에서 이 세 개의 값을 활용하여 특정 시나리오에 따라 Range를 동적으로 변경하거나 다양한 응용을 통해 재미있는 구현이 가능해집니다.
SelectionStart, SelectionEnd 그리고 IsSelectionRangeEnabled:
이 두 속성(SelectionStart/SelectionEnd)은 특별한 영역을 설정하는 역할을 합니다. 사실 이 영역이 특별한 기능을 포함하고 것은 아닙니다. 단지 어느 구간을 지정하고 시각적으로 강조하기 위함입니다. 그리고 IsSelectionRangeEnabled은 이 영역의 활성화 여부를 나타내는 속성입니다. 그리고 이 활성화 여부에 따라 트리거를 통해 영역의 Visibility 속성 값이 스위칭 됩니다. (Visible/Collapsed)
결과적으로 이 기능들을 살펴보면, 단순한 영역 표시에 불과하기 때문에 굳이 이 기능이 존재해야 하는지에 대해 의문이 듭니다. 하지만 디자인과 분야에 따라 범용성 있게 사용되는 만큼, 그 필요성에 대해 이해해보고 예상해볼 수도 있을 것입니다. 20년전 스타일 취향 존중
그런데 사실, 이것을 Value 값과 함께 응용한다면 아주 흥미로운 효과를 나타낼 수 있는데, 바로 아래와 같습니다.
<Slider Orientation="Horizontal"
Minimum="0"
Maximum="100"
Value="30"
SelectionStart="0"
SelectionEnd="{Binding Value, RelativeSource={RelativeSource Self}"
IsSelectionRangeEnabled="True"/>
놀랍게도, Value값이 SelectionEnd Binding을 통해 연결되어 값이 변결될 때마다 Selection (Range) 범위가 동적으로 변경되는 효과를 얻을 수 있습니다. WPF 개발진이 의도한걸까요? 멋집니다, 구현 방식도 매우 깔끔해서 기분가지 좋아집니다.
글 후반부에 있을 Riot 스타일의 Slider (CustomControl) 구현에서 아주 알짜배기 역할을 하게 될 것입니다, 살짝 기억해주세요.
5. 원본 스타일 추출 과정
앞서 언급한 것처럼, WPF는 GitHub 레포지토리를 통해 오픈소스로 관리되기 때문에 모든 컨트롤의 소스코드를 살펴볼 수 있습니다. 하지만 레포지터리에는 솔루션을 비롯해 모든 프로젝트 및 파일이 포함되어 있기 때문에, 특정 컨트롤 부분의 내용만 추출하는 것은 불가능에 가까울 정도로 어려운 작업입니다.
다행이도, 비주얼스튜디오는 특정 컨트롤의 기본 스타일을 (Template) 추출하는 기능을 GUI 형태로 제공합니다. 따라서 오픈소스를 찾아 헤메는 절차 없이도 손쉽고 간단하게 이를 활용한 해당 코드 추출이 가능해집니다.
마치 Balzor에서의 Identity 스케폴딩을 떠올리는 것도 괜찮습니다. (성격은 조금 다르지만 이해를 돕기 위해)
또한 비주얼스튜디오를 통해 원본 스타일을 추출하게 되면 실제 수정 가능한 리소스 형태로 연결되기 때문에 디자인과 기능을 바로 커스터마이징하여 사용할 수 있게 됩니다. 따라서 Slider뿐만 아니라 모든 컨트롤의 원본 스타일 및 템플릿 추출이 가능하기 때문에 WPF 연구/학습에 있어 활용 가치가 아주 높은 요소입니다.
Infragistics, Syncfusion, ArticPro 같은 상용 컴포넌트를 살펴보면, 이 추출 기능을 무조건 제공하는 것은 아닙니다. 회사마다 공개하는 범위나 정책이 각기 다르며, 대부분의 경우 ControlTemplate을 공개하기보다는 DataTemplate으로 모듈화 하여 커스터마이징할 수 있도록 유도하는 정책을 선호합니다. 따라서 여러분이 사용중인 컴포넌트에 대해서는 흥미롭게 한번 살펴보시기 바랍니다.
추출 방법과 절차: Visual Studio
- 기본 컨트롤 (Slider) 스타일 추출하기 (Edit a Copy…)
- 현재 파일에 추출 (This document)
- App.xaml 파일에 추출 (Application)
- 새로운 ResourceDictionary 파일을 생성해서 추출 (Resource Dictionary)
단, 추출 절차는 Partial 형태의 UserControl 화면의 디자인 영역에서만 진행할 수 있으며, 컨트롤을 선택하고 우측 클릭을 통해 절차를 진행합니다. 이 과정에서 “스타일 이름 지정/추출 스타일의 복사 위치를 지정” 옵션을 선택하는 단계를 거칩니다.
VScode 또는 Rider에서의 방법도 한번 찾아보세요, 제공하고 있을까요?
과정을 단계적으로 살펴봅시다.
- 스타일 추출 명령: Slider > Right click > Edit Template > Edit a Copy…
제공되는 추출 스타일이 없는 경우에는 이 항목이 활성화되지 않음,
- 스타일 추출 옵션 창: Create ControlTemplate Resource (Window)
Name (Key) 그리고 Define in 옵션 선택,
일반적으로는 Name을 지정하는 것이 테스트나 관리적인 측면에서 옳은 선택입니다. 이를 지정하지 않고 “Apply to all” 항목을 선택하여 추출할 경우 Define 항목을 통해 지정한 위치를 기준으로 생성된 스타일이 전역으로 적용됩니다. 따라서 이 점을 제대로 이해하고 신중하게 추출을 진행하도록 합니다.
영상에서는 이름을 설정하고, Define 위치를 Application으로 지정하고 있습니다. 따라서 (파일이 존재하는 경우) App.xaml 파일의 Resources 영역 안에 추출된 리소스가 포함됩니다.
개인적인 의견으로는 이러한 추출 작업을 할 때, 되도록이면 신규 프로젝트에서 테스트 성격으로 진행하는 방식을 권합니다. 실제로 이 과정을 라이브 프로젝트에서 진행할 경우 사소한 실수, 문제들이 생길 수도 있기 때문에 이러한 사이드 이펙트를 방지하는 차원에서도 좋은 선택입니다.
6. 추출된 소스코드 분석
튜토리얼 영상에서처럼 Slider 컨트롤의 스타일이 성공적으로 추출되었습니다. App.xaml 파일 안에 관련 리소스들을 확인해보고, 중요하게 눈여겨볼 요소들을 하나씩 확인해봅시다.
Orientation 분기 확인:
앞 부분에서 Orientation 속성을 설명할 때 잠깐 트리거와 스위칭에 대해 간략하게 언급했지만, 이번에는 구현되어 있는 실제 소스코드를 확인해볼 차례입니다.
아래 스타일은 추출된 SliderStyle1 이름의 템플릿이 포함된 WPF 기본 스타일 원본입니다. (실제로 에러 없이 바로 적용되어 동작도 합니다.)
<Style x:Key="SliderStyle1" TargetType="{x:Type Slider}">
<Setter Property="Stylus.IsPressAndHoldEnabled" Value="false"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="Foreground"
Value="{StaticResource SliderThumb.Static.Foreground}"/>
<Setter Property="Template" Value="{StaticResource SliderHorizontal}"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Template" Value="{StaticResource SliderVertical}"/>
</Trigger>
</Style.Triggers>
</Style>
내용을 살펴보면 기본 Template은 SliderHorizontal (ControlTemplate) 템플릿이 지정되어 있고, 트리거를 통해 Orientation 속성 값이 Vertical일 경우 SliderVertical (ControlTemplate) 템플릿으로 스위칭 되는 부분을 확인할 수 있습니다.
이처럼 (ControlTemplate) 템플릿을 모듈화하여 관리하면 실제 스타일의 형태를 한눈에 볼 수 있는 장점이 생깁니다, 꼭 스위칭하지 않는 상황이더라도 한번 해볼법한 관리 구조 방식입니다. 저는 자주 합니다. 이런 부분들을 통해서도 영감을 받을 수 있죠,
따라서 Slider 컨트롤과 관련한 기능이 실질적으로 SliderHorizontal 그리고 SliderVertical 두 (ControlTemplate) 템플릿 영역에 각각 구현되어 있습니다.
이제 기본으로 지정되어 있는 SliderHorizontal (ControlTemplate) 템플릿을 확인해봅시다.
ControlTemplate 확인:
각각의 Horizontal/Vertical 전용 템플릿을 확인해봅시다. 모두 계속해서 App.xaml 파일 안에서 찾아볼 수 있습니다.
- Horizontal 전용 템플릿 확인
- Vertical 전용 템플릿 확인
ControlTemplate: SliderHorizontal
<ControlTemplate x:Key="SliderHorizontal" TargetType="{x:Type Slider}">
<Border ...>
...
</Border>
<ControlTemplate.Triggers>
...
</ControlTemplate.Triggers>
</ControlTemplate>
ControlTemplate: SliderVertical
<ControlTemplate x:Key="SliderVertical" TargetType="{x:Type Slider}">
<Border ...>
...
</Border>
<ControlTemplate.Triggers>
...
</ControlTemplate.Triggers>
</ControlTemplate>
위와 같이 Horizontal/Vertical 각각의 소스코드가 분기되어 구현되어있는 것을 확인할 수 있습니다. 따라서 구현된 내용은 둘 다 동일하며, 단지 디자인적인 측면에서의 방향만 다를 뿐입니다.
이를 정확히 확인해봅시다. 공통으로 포함된 요소들은 다음과 같습니다.
- Name: TopTick
- Name: BottomTick
- Name: TrackBackground
- Name: PART_SelectionRange
- Name: PART_Track
- Name: Thumb
- Trigger: TickPlacement
- Trigger: IsSelectionRangeEnabled
- Trigger: IsKeyboardFocused
공통으로 포함된 요소들이 각각의 ControlTemplate 모두에 포함된 것을 확인할 수 있습니다. 둘 다 동일한 구성이라는 것을 확인했으니, 이제는 SliderHorizontal 부분만 집중해서 살펴봅시다.
Naming rule: PART_
(CustomControl) 컨트롤 구조에서는 XAML과 Code behind간의 연결을 긴밀하게 가져가는 것이 매우 중요한 요소입니다. 하지만 연결을 하기 위해서는 GetTemplateChild 메서드를 통해 컨트롤 이름을 찾기 때문에 가독성 측면에서 좋지 않아보입니다. 이러한 개발 방식을 보완하고 체계적으로 관리하기 위해 PART_
네이밍 규칙을 사용합니다.
이는 GetTemplateChild를 통해 찾는 모든 컨트롤 이름에 PART_
를 접두어가 붙어, XAML 상에서의 기능을 짐작할 수 있도록 하기 위한 네이밍 규칙입니다. 따라서 (ControlTemplate) 컨트롤을 분석할 때, PART_
로 시작하는 이름의 컨트롤을 발견한다면, 필수요소일 가능성 짐작이 가능하며 이를 지웠을 때 발생하게 될 사이드 이펙트를 미리 예상하는 것이 가능해집니다.
결론적으로는 CustomControl 구현에 있어 큰 도움이 됩니다. 또한 이 규칙은 WPF 뿐만 아니라, XAML을 공유하는 다른 크로스플랫폼에서도 흔히 볼 수 있는 공통적인 구조이므로, 높치지 말아야 할 중요한 부분임을 다시 한 번 강조합니다.
Slider에서는 두 개의 PART_
컨트롤이 존재합니다.
- PART_Track
- PART_SelectionRange
결과적으로 위 두 개의 PART_
컨트롤 외의 나머지 컨트롤은 Code behind에서 사용되지 않습니다. 이를 네이밍 규칙을 통해 보장하는 것입니다. 따라서 우리도 CustomControl 개발에 있어 이 규칙을 철저하게 지키는 것이 매우 중요합니다.
테스트: PART_Track 의도적인 이름 변경 후 영향 체크
PART_Track
컨트롤 이름을 의도적으로 변경해봅시다.
<Track x:Name="PART_Track1" Grid.Row="1">
...
</Track>
Sliderhorizontal 영역이 맞는지 잘 확인해보세요.
이제 애플리케이션을 실행하면, 튜토리얼 영상에서처럼 아무리 드래그를 통해 Track의 Thumb를 움직여봐도 더 이상 좌우로 이동하지 않게 됩니다. Thumb가 더 이상 움직이지 않는 이유는 앞서 의도적인 이름 변경으로 인해 Code behind 영역에서 GetTemplateChild를 통해 PART_Track
컨트롤을 찾을 찾을 수 없기 때문입니다.
따라서 PART_Track
컨트롤을 찾지 못했기 때문에, 마우스 드래그를 통해 움직일 Thumb 대상이 존재하지 않게 된 것입니다. 다시 PART_Track1
이름을 원래대로 되돌린다면 기능이 다시 정상으로 돌아올 것입니다.
이런 현상은 다른 많은 기본 컨트롤들에서도 찾아볼 수 잇는데, 대표적으로는 TextBox의
PART_ContentHost
가 그 중의 하나입니다.
테스트: PART_SelectionRange 의도적인 이름 변경 후 영향 체크
이어서 PART_SelectionRange
컨트롤 이름도 의도적으로 변경해봅시다.
<Rectangle x:Name="PART_SelectionRange1" .../>
Sliderhorizontal 영역이 맞는지 잘 확인해보세요. (x2)
그리고 트리거 부분을 살펴보면 PART_SelectionRange
를 사용하는 부분이 더 있기 때문에 이 부분도 함께 변경해야 합니다.
<Trigger Property="IsSelectionRangeEnabled" Value="true">
<Setter Property="Visibility" TargetName="PART_SelectionRange1" Value="Visible"/>
</Trigger>
Sliderhorizontal 영역이 맞는지 잘 확인해보세요. (x3)
그리고 Sldier에서도 아래처럼 PART_SelectionRange
를 활성화하기 위한 속성들을 모두 설정하도록 합니다.
<Slider Style="{DynamicResource SliderStyle1}"
Minimum="0" Maximum="100"
SelectionStart="0" SelectionEnd="50"
IsSelectionRangeEnabled="True"/>
Minimum/Maximum 그리고 SelectionStart/SelectionEnd, IsSelectionRange까지 모두 설정해야 Range 영역을 활성화할 수 있습니다.
- 이름 변경 전: PART_SelectionRange
변경 전, 정상적으로 보이는 Rage 영역을 확인할 수 있습니다.
- 이름 변경 후: PART_SelectionRange1
이제는 더 이상 Range 영역이 보이지 않습니다.
이번에도 역시 PART_SelectionRange
컨트롤을 내부적으로 찾을 수 없기 때문에 Range 영역을 계산할 대상이 없게 된 것입니다.
이처럼 WPF 컨트롤은 생각보다 기능이 느슨하게 구현되어 있으면서 나름의 모듈화 구조를 구성하고 있습니다. 따라서 이러한 특성을 잘 이용한다면, 이미 구현되어 있는 기능을 잘 활용하거나 또는 불필요한 기능을 제외시키는 것도 가능해집니다.
7. Code behind 확인 (GitHub 오픈소스)
앞서 PART_
컨트롤의 네이밍 규칙과 영향에 대해 자세하게 살펴봤으니, 이번에는 실제 클래스에서 이 컨트롤이 어떻게 사용되는지를 찾아볼 차례입니다.
Code behind (클래스) 영역은 더 이상 추출을 통해 확인해볼 수 있는 영역이 아닙니다. 따라서 WPF 레포지터리를 통해 Official 소스코드를 살펴봐야 합니다. 이를 찾는 방법은 튜토리얼 영상을 통해 더 자세하게 살펴보는 것을 권합니다.
실제 소스코드에서는 각각의 PART_
컨트롤 이름이 아래와 같이 string
으로 약속되어 있습니다.
private const string TrackName = "PART_Track";
private const string SelectionRangeElementName = "PART_SelectionRange";
이름이 고정으로 정의되어 있기 때문에 반드시 지켜야 하는 네이밍 규칙인 것입니다.
WPF: OnApplyTemplate
다음은 Track과 SlectionRange를 (ControlTemplate) 템플릿으로부터 가져오는 부분을 살펴봅시다.
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
SelectionRangeElement = GetTemplateChild(SelectionRangeElementName) as FrameworkElement;
Track = GetTemplateChild(TrackName) as Track;
if (_autoToolTip != null)
{
_autoToolTip.PlacementTarget = Track != null ? Track.Thumb : null;
}
}
(Override) OnApplyTemplate 메서드는 클래스와 스타일이 연결된 후 호출되므로 GetTemplateChild를 사용하기 위한 최적의 시점이라는 것을 알아둡시다.
원본 소스코드를 살펴보면 각각 FrameworkElement와 Track으로 정의되어 있습니다.
- PART_SelectionRange: SelectionRangeElement (FrameworkElement)
- PART_Track: TrackName (Track)
여기서 주목할 점이 있습니다. Track의 경우 XAML과 동일한 타입이지만 SelectionRange는 원본의 Rectangle과는 다른 FrameworkElement로 되어 있는데, 이는 Range 영역을 Rectangle 뿐만 아니라 어떠한 컨트롤을 사용해도 된다는 된다는 의미로 해석해도 무방합니다. 의도적으로 타입이 유연하게 정의되어 있는 것입니다.
그렇다면 (FrameworkElement 타입으로 정의된) SelectionRangeElement는 이 타입에서 다룰 수 있는 기본적인 기능만을 처리할 것으로 예상을 해볼 수 있습니다.
다음은 실제 SelectionRangeElement를 다루는 부분입니다.
private void UpdateSelectionRangeElementPositionAndSize()
{
Size trackSize = new Size(0d, 0d);
Size thumbSize = new Size(0d, 0d);
if (Track == null || DoubleUtil.LessThan(SelectionEnd,SelectionStart))
{
return;
}
trackSize = Track.RenderSize;
thumbSize = (Track.Thumb != null) ? Track.Thumb.RenderSize : new Size(0d, 0d);
double range = Maximum - Minimum;
double valueToSize;
FrameworkElement rangeElement = this.SelectionRangeElement as FrameworkElement;
if (rangeElement == null)
{
return;
}
if (Orientation == Orientation.Horizontal)
{
// Calculate part size for HorizontalSlider
if (DoubleUtil.AreClose(range, 0d) || (DoubleUtil.AreClose(trackSize.Width, thumbSize.Width)))
{
valueToSize = 0d;
}
else
{
valueToSize = Math.Max(0.0, (trackSize.Width - thumbSize.Width) / range);
}
rangeElement.Width = ((SelectionEnd - SelectionStart) * valueToSize);
if (IsDirectionReversed)
{
Canvas.SetLeft(rangeElement, (thumbSize.Width * 0.5) + Math.Max(Maximum - SelectionEnd, 0) * valueToSize);
}
else
{
Canvas.SetLeft(rangeElement, (thumbSize.Width * 0.5) + Math.Max(SelectionStart - Minimum, 0) * valueToSize);
}
}
else
{
// Calculate part size for VerticalSlider
if (DoubleUtil.AreClose(range, 0d) || (DoubleUtil.AreClose(trackSize.Height, thumbSize.Height)))
{
valueToSize = 0d;
}
else
{
valueToSize = Math.Max(0.0, (trackSize.Height - thumbSize.Height) / range);
}
rangeElement.Height = ((SelectionEnd - SelectionStart) * valueToSize);
if (IsDirectionReversed)
{
Canvas.SetTop(rangeElement, (thumbSize.Height * 0.5) + Math.Max(SelectionStart - Minimum, 0) * valueToSize);
}
else
{
Canvas.SetTop(rangeElement, (thumbSize.Height * 0.5) + Math.Max(Maximum - SelectionEnd,0) * valueToSize);
}
}
}
Orientation을 분기하는 (Horizontal/Vertical) 로직은 실제로 동일하기 때문에 Horizontal을 기준으로만 살펴보면 됩니다.
바로 이 (UpdateSelectionRangeElementPositionAndSize) 메서드를 통해 SelectionRange의 사이즈 및 포지션이 결정됩니다. 소스코드의 양이 다소 부담스럽다고 생각할 수도 있지만 Orientation을 분기하는 로직의 중복된 소스코드를 감안하면 SelectionRange에 대한 간결한 처리가 이루어지고 있음을 쉽게 파악할 수 있습니다.
이처럼 (CustomControl) 컨트롤을 추출하고 PART_
컨트롤이 내부에서 어떻게 처리되는지를 이와 같이 역으로 찾아 들어가서 분석이 가능한 것입니다.
8. 크로스플랫폼에서의 OnApplyTemplate
WPF의 설계의 많은 부분을 고유하고 있는 크로스플랫폼들 역시 이와 같은 흐름을 그대로 유사하게 따르고 있습니다. 따라서 앞서 분석해본 OnApplyTemplate를 기준으로 다른 플랫폼에서도 한번 이를 살펴봅시다.
OnApplyTemplate 설계를 공유하는 플랫폼 목록
- AvaloniaUI
- Uno Platform
- OpenSilver
- MAUI
- Xamarin
- UWP
- WinUI 3
- Sliverlight
이 항목 중에서 체크된 AvaloniaUI, Uno Platform, OpenSilver, MAUI, Xamarin의 실제 원본 소스코드를 한번 살펴보겠습니다.
참고로 Silverlight 빼고는 모두 GitHub의 Microsoft 공식 Organization인 Dotnet 또는 xamarin을 통해 관리되고 있기 때문에 레포지토리를 GitHub에서 쉽게 찾아볼 수 있습니다.
AvaloniaUI: OnApplyTemplate
아래는 AvaloniaUI에서의 Slider 컨트롤의 OnApplyTemplate 부분입니다.
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
...
base.OnApplyTemplate(e);
_decreaseButton = e.NameScope.Find<Button>("PART_DecreaseButton");
_track = e.NameScope.Find<Track>("PART_Track");
_increaseButton = e.NameScope.Find<Button>("PART_IncreaseButton");
...
}
AvaloniaUI 역시 오픈소스로 관리되고 있기 때문에 WPF처럼 모든 소스코드를 살펴볼 수 있습니다. 또한 WPF와도 매우 비슷한 방식이라는 것을 알 수 있습니다.
이처럼 네이밍 규칙을 통해 세 개의 PART_
컨트롤이 XAML영역에서 필수 구성 요소로써 동작한다는 것을 단번에 파악할 수 있습니다. Uno도 살펴볼까요?
Uno Platform: OnApplyTemplate
protected override void OnApplyTemplate()
{
...
base.OnApplyTemplate(e);
// Get the parts
var spElementHorizontalTemplateAsDO = GetTemplateChild("HorizontalTemplate");
_tpElementHorizontalTemplate = spElementHorizontalTemplateAsDO as FrameworkElement;
var spElementTopTickBarAsDO = GetTemplateChild("TopTickBar");
...
}
Uno에서도 마찬가지로 WPF와 유사한 방식입니다.
다만 Uno는 의외로 PART_
네이밍 규칙을 따르지 않고 있습니다. 아마도 처음부터 규칙을 사용하지 않는 것을 규칙으로 정한 것 같습니다.
MAUI와 OpenSilver 그리고 Xamarin에서도 이러한 소스코드를 찾아볼 수 있습니다.
MAUI: OnApplyTemplate
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_thumb = (Thumb)GetTemplateChild("HorizontalThumb");
_originalThumbStyle = _thumb.Style;
UpdateThumbStyle();
}
WPF에서는 Track과 같이 변수 이름을 선언하지만 MAUI에서는 언더바를 붙이고 있습니다. 각각의 플랫폼마다의 네이밍 규칙과 개발 패턴을 비교해보는 것 또한 오픈소스 분석에 있어 작은 즐거움 중 하나입니다.
OpenSilver: OnApplyTemplate
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
// Get the parts
...
ElementVerticalThumb = GetTemplateChild(ElementVerticalThumbName) as Thumb;
...
}
Uno와 비슷한 스타일의 주석을 사용하는군요.
Xamarin: OnApplyTemplate
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
FormsContentControl = Template.FindName("PART_Multi_Content", this)
as FormsTransitioningContentControl;
}
조금씩은 다르지만 모두 WPF와 같은 설계를 공유하고 있는 것을 확인했습니다.
9. Slider 분석을 마치며
WPF Slider 컨트롤을 잘 살펴보았습니다. 이를 통해 우리는 WPF의 (CustomControl) 컨트롤이 매우 정교하게 잘 설계되어 있다는 것을 확인했습니다. 또한 이러한 규칙들은 다른 컨트롤에도 동일하게 응용되며, 또 새로운 컨트롤을 설계하는데에 있어서도 중요한 기반으로 쓰이게 될 것입니다.
누군가는 WPF가 죽었다고 (Is WPF Dead) 표현합니다. 하지만 WPF는 여전히 사라지지 않았으며 계속해서 자리를 지키고 있습니다. WPF를 깊이 있게 다룬다는 것은 계속해서 무긍무진한 가능성과 재미를 가져다 줍니다.
WPF로 모든 개발을 하고 싶다는 꿈이 과거에는 상상으로만 그쳤다면, Xamarin과 닷넷 코어를 시작으로 현재까지 생겨난 다양한 플랫폼들을 통해 이제는 더 이상 꿈이 아닌 현실이 되었습니다. 이는 WPF를 사랑하는 많은 개발자들의 소망과 기여가 모여 만들어진 결과입니다.
지금까지 기본 컨트롤의 분석이 왜 필요한지에 대해 자세하게 살펴봤습니다. 다시 한 번 튜토리얼 영상의 설명을 통해 내용들을 복기하고 학습하는 것을 권합니다.
다음은 이 분석을 기반으로 새로운 Riot 스타일의 (CustomControl) Slider를 만들어보도록 하겠습니다.