天天看點

用WPF實作《英雄聯盟》風格滑塊|深入剖析

作者:opendotnet

控件名稱:RiotSlider

作者:Vicky&James

源碼連結: https://github.com/vickyqu115/riotslider教學視訊(https://www.bilibili.com/video/BV1uy421a7yM)

這篇文章是對 WPF RiotSlider 教程視訊的技術回顧,可以搜尋我們的賬号觀看完整版視訊教學内容。
用WPF實作《英雄聯盟》風格滑塊|深入剖析
分析和自定義 WPF Slider 控件的詳細機制

在 WPF 中,像Button和ToggleButton這樣的基本控件結構和邏輯簡單,可以完全用 XAML 實作而無需背景代碼。相比之下,更複雜的控件如TextBox、ComboBox和Slider則需要複雜的 C# 代碼配合 XAML 才能實作它的功能。

了解和應用 WPF 控件的複雜配置,可以讓自定義控件的設計和開發更為優雅和靈活。熟練掌握這些基本元件,我們就可以解決 MVVM 開發模式中的一些問題,進而建構出更加高品質的 WPF 應用程式。

這次我們對 WPF Slider 控件的探索就是為了更深入了解這個控件建構的方式和它的内部機制。盡管代碼量非常龐大,要深入每個 WPF 控件的内部幾乎是不可能的,但也無需過于擔心。

WPF 的所有源代碼都是開放的,并在 GitHub 上進行管理。這就意味着我們可以随時根據需要找到并分析具體的控件。

除了 Slider 控件之外,未來我們還計劃剖析和分析更多更複雜和多樣化的控件。是以如果你對這一部分的内容有興趣,不要忘了關注我們Bilibili或Youtube頻道,具體的源代碼我們也會分享在GitHub上。

用WPF實作《英雄聯盟》風格滑塊|深入剖析

内容目錄

  1. WPF 系列教程
  2. 規範
  3. 建立應用項目
  4. 分析 Slider 的主要功能
  5. 提取原始樣式過程
  6. 提取源碼分析
  7. 檢查背景代碼(GitHub 開源)
  8. 跨平台的 OnApplyTemplate
  9. 總結 Slider 分析
  10. 建立 Riot 風格 Slider(自定義控件)
  11. 項目建立和開始準備
  12. TextBlock(Hi Slider)
  13. 添加引用和測試執行
  14. 設定 Riot Slider 的尺寸
  15. PART_Track
  16. 添加滑動條
  17. 調整滑動條和軌道之間的間隙
  18. PART_SelectionRange
  19. 添加 Riot 風格設計元素
  20. 實作 Riot 風格的滑塊
  21. 聲明滑塊資源
  22. 完成 RiotSlider 模闆(最後潤色)
  23. 最終評論

WPF 系列教程

目前為止,我們已經在 YouTube 和 BiliBili 上釋出了四個教程系列。并提供了英文和中文配音,及韓文字幕。我們希望通過這些視訊,結合精細的源碼和詳細講解,能提升大家對 WPF 的了解。
  • [x] 主題切換:BiliBili,GitHub
  • [x] Riot 播放按鈕:BiliBili,GitHub
  • [x] 魔法導航欄:BiliBili,GitHub
  • [x] Riot 滑塊:BiliBili,GitHub

規範

該項目基于 .NET Core,但由于使用了 WPF,是以僅限于 Windows 平台。 該項目可以通過 VS2022 運作,運作條件:.NET 8.0 或者,也可以在JetBrains 的 Rider上運作此項目。

  • [x] 作業系統:Microsoft Windows 11
  • [x] IDE:Microsoft Visual Studio 2022
  • [x] 版本:C# / .NET 8.0 / WPF / 僅限 Windows
  • [x] NuGet:Jamesnet.Wpf

建議使用最新版本的 Windows 作為作業系統。當然,如果考慮将平台擴充到 Avalonia UI、Uno Platform、MAUI 等,也可以将 MacOS 作為輔助裝置。我們的程式設計環境中也同時使用 Thinkpad 和 MacBooks。需要注意的是,Visual Studio 在 MacOS 或基于 Linux 的系統上不可用,是以 Rider 是唯一的替代品。vscode

3. 建立應用項目

首先,我們需要建立一個 WPF Application項目。

  • [x] 項目類型:WPF Application
  • [x] 項目名稱:DemoApp
  • [x] 項目版本:.NET 8.0

4. Slider 主要功能分析

與 Button 這樣的簡單控件不同,WPF Slider 控件具有非常多的屬性。特别是這些屬性在控件功能中起着重要作用,是以值得我們仔細研究。其中一些特殊的主要屬性如下:

Orientation:

WPF 提供的控件通常具有通用性。Slider 控件也不例外,Orientation 屬性就是一個例子。通過這個屬性,可以指定水準或垂直方向。

Orientation 屬性也可以在 StackPanel 控件中找到。StackPanel 的 Orientation 屬性預設值為 Vertical,但 Slider 的 Orientation 屬性預設值為 Horizontal。是以,通常情況下,Slider 是以 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 切換模闆的概念嗎?開源項目确實能以這樣的方式激發開發者的靈感。另外,通過這段代碼,我們可以确認,切換模闆的最佳時機是在“Style.Trigger”中。

在本次教程視訊中,我們隻會實作 Horizontal 方向的功能,是以不會通過 Orientation 進行分支切換。不過,也建議大家可以嘗試實作 Vertical 方向,并通過 Fork 向我們送出 Pull Request,這是一個小任務哦。

那麼接下來,讓我們看看分别應用 Horizontal 和 Vertical 屬性的效果吧:

  • [x] Orientation: Horizontal
用WPF實作《英雄聯盟》風格滑塊|深入剖析
在下面即将讨論的 SelectionRange(藍色)區域也可以看到。
  • [x] Orientation: Vertical
用WPF實作《英雄聯盟》風格滑塊|深入剖析
類似于這種切換(ControlTemplate)模闆的控件還有很多,比如 ScrollViewer 等。
Minimum, Maximum 和 Value:

這些屬性分别表示最小值/最大值和目前值,他們都是 double 類型的屬性。内部會根據這些值自動計算控件的大小和比例,以及 Range 和 Value 值的位置。

由于這些屬性都是 DependencyProperty,是以可以通過綁定實作動态互動。例如,在 MVVM 結構中,可以利用這三個值根據特定場景動态更改範圍或實作各種有趣的應用。

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 綁定,每次值發生變化時,Selection (Range) 範圍都會動态變化。不知道WPF 的開發團隊是不是有意為之呢?總之非常棒,實作方式也非常簡潔。

在後面将要實作的 Riot 風格 Slider (CustomControl) 中,這個部分起到非常重要的作用,請大家注意。

5. 提取原始樣式的過程

如上述内容所示,WPF 通過 GitHub 倉庫以開源方式進行管理,是以我們可以檢視所有控件的源代碼。但是由于倉庫中包含了解決方案以及所有項目和檔案,是以僅提取特定控件部分的内容就非常困難了。

幸運的是,Visual Studio 提供了提取特定控件預設樣式(Template)的功能,并以 GUI 形式呈現。是以,我們無需在開源代碼中一步步尋找也可以輕松提取相應的代碼。

類似于 Blazor 中的 Identity Scaffolding(雖然性質稍有不同,但為了幫助了解,可以這樣類比)。

除此之外,通過 Visual Studio 提取原始樣式後,實際會以可修改的資源形式連接配接,是以就可以馬上自定義設計和功能。是以,不僅是 Slider,所有控件的原始樣式和模闆都可以提取,這在 WPF 的研究和學習的過程中具有很高的應用價值。

Infragistics、Syncfusion、ArticPro 等商用元件中并不一定提供這種提取的功能。每家公司公開的範圍和政策都不同,在大多數情況下,更傾向于通過 DataTemplate 而非 ControlTemplate 來子產品化并引導自定義。是以,如果大家有興趣的話也可以研究一下正在用的元件是什麼的方式。
提取方法和步驟:Visual Studio
  • [x] 提取預設控件(Slider)樣式(Edit a Copy...)
  • [x] 提取到目前檔案(This document)
  • [x] 提取到 App.xaml 檔案(Application)
  • [x] 建立新的 ResourceDictionary 檔案并提取(Resource Dictionary)

不過,提取步驟隻能在 Partial 形式的 UserControl 界面的設計區域進行,可以選擇控件并右鍵點選來進行操作。在這個過程中,需要選擇“指定樣式名稱/指定提取樣式的複制位置”選項。

可以試着在 VScode 或 Rider 中也查找一下,看看是否提也供類似功能?

讓我們來逐漸檢視這個過程。

  • [x] 樣式提取指令:Slider > 右鍵點選 > Edit Template > Edit a Copy...
用WPF實作《英雄聯盟》風格滑塊|深入剖析
如果沒有提供可提取的樣式,那麼這個選項将不會被激活。
  • [x] 樣式提取選項視窗:Create ControlTemplate Resource(Window)
用WPF實作《英雄聯盟》風格滑塊|深入剖析
選擇 Name(Key)和 Define in 選項,

通常,指定名稱是測試或管理方面的正确選擇。如果不指定名稱而選擇“Apply to all”項,生成的樣式将根據定義的提取位置全局應用。是以需要充分了解這一點并謹慎進行提取。

在視訊中,設定了名稱,并将定義位置指定為 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 時,Template 切換為 SliderVertical(ControlTemplate)。

這樣通過子產品化管理 ControlTemplate 模闆,可以一目了然地看到樣式的結構。即使在不需要切換的情況下,這也是一種值得嘗試的管理方式。我們經常使用這樣的方法,并且從這個過程中也能獲得靈感。

是以,Slider 控件的實際功能分别在 SliderHorizontal 和 SliderVertical 兩個 ControlTemplate 模闆中實作。

現在我們來檢視一下預設設定的 SliderHorizontal(ControlTemplate)模闆。

确認ControlTemplate:

分别檢視 Horizontal/Vertical 專用模闆。在App.xaml 檔案中可以找到。

  • [x] 檢查 Horizontal 專用模闆
  • [x] 檢查 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 的源代碼被分别實作。兩者的實作内容相同,僅在設計方向上有所不同。

名稱:

PART_

在自定義控件的結構中,保持 XAML 和代碼後端的緊密連接配接是非常重要的。然而,為了進行這種連接配接,需要通過

GetTemplateChild

方法找到控件名稱,這在可讀性方面并不理想。為了改善這種開發方式并進行系統化管理,使用了

PART_

命名規則。

這是一個命名規則,它在所有通過

GetTemplateChild

找到的控件名稱前加上

PART_

字首,以便在 XAML 中推測其功能。是以,在分析控件模闆時,如果發現以

PART_

開頭的控件名稱,可以猜測它們是必需的元素,并提前預料到删除它們可能産生的副作用。

最終,這對于實作自定義控件是非常有幫助的。此外,這一規則不僅在 WPF 中适用,在共享 XAML 的其他跨平台架構中也常見,是一個重要且不可忽視的部分。

Slider

控件中的兩個

PART_

控件

  • PART_Track

  • PART_SelectionRange

結果表明,除了這兩個

PART_

控件外,其他控件在代碼後端中都不會使用。通過這個命名規則可以保證這一點。是以,在自定義控件開發中嚴格遵守這一規則是非常重要的。

測試:有意更改

PART_Track

的名稱後檢查影響

現在,我們有意更改

PART_Track

控件的名稱。

<Track x:Name="PART_Track1" Grid.Row="1">
 ...
</Track>
           
請仔細檢查是否在

SliderHorizontal

區域内。

運作應用程式時,如同在教程視訊中那樣,無論如何拖動,

Track

Thumb

都不會左右移動。

Thumb

無法移動的原因是,由于之前有意更改了名稱,代碼後端無法通過

GetTemplateChild

方法找到

PART_Track

控件。

是以,由于找不到

PART_Track

控件,拖動滑鼠時沒有目标

Thumb

來移動。将名稱

PART_Track1

恢複到原來的

PART_Track

後,功能将恢複正常。

這種現象在許多其他基本控件中也可以找到,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)

Slider

中,我們還需要設定如下屬性來啟用

PART_SelectionRange

<Slider Style="{DynamicResource SliderStyle1}"
Minimum="0" Maximum="100"
SelectionStart="0" SelectionEnd="50"
IsSelectionRangeEnabled="True"/>
           
需要設定

Minimum

Maximum

以及

SelectionStart

SelectionEnd

IsSelectionRange

等屬性,才能啟用範圍區域。

更改前:

PART_SelectionRange

用WPF實作《英雄聯盟》風格滑塊|深入剖析
在更改前,可以看到正常顯示的範圍區域。

更改後:

PART_SelectionRange1

用WPF實作《英雄聯盟》風格滑塊|深入剖析
更改後,範圍區域不再顯示。

同樣,由于無法在内部找到

PART_SelectionRange

控件,無法計算範圍區域的目标。

由此可見,WPF控件雖然功能建構相對松散,但卻建構了一個子產品化的結構。是以,如果我們能夠利用好這些特性,不僅可以很好的利用已經實作的功能,還可以排除很多不必要的功能。

7. Code behind 确認 (GitHub 開源代碼)

前面我們詳細讨論了

PART_

控件的命名規則及其影響,現在是時候看看這些控件在實際類中是如何使用的。

Code behind(類)區域無法通過直接提取來确認。是以,我們需要通過WPF代碼庫中的官方源代碼檢視。這個部分建議檢視我們的視訊教程了解具體的檢視方法。

在實際的源代碼中,每個

PART_

控件的名稱都如下所示地定義為

string

private const string TrackName = "PART_Track";
private const string SelectionRangeElementName = "PART_SelectionRange";
           
因為名稱是固定定義的,是以必須遵守這個命名規則。
WPF: OnApplyTemplate

接下來我們從(ControlTemplate) 模版中擷取 Track 和 SlectionRange的這個部分開始檢視。

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

 SelectionRangeElement = GetTemplateChild(SelectionRangeElementName) as FrameworkElement;
 Track = GetTemplateChild(TrackName) as Track;

if (_autoToolTip != )
 {
 _autoToolTip.PlacementTarget = Track !=  ? Track.Thumb : ;
 }
}
           
注意:(Override) OnApplyTemplate 方法在類和樣式關聯後調用,是以這是使用 GetTemplateChild 的最佳時機。

檢視源代碼,我們可以看到它們分别被定義為 FrameworkElement 和 Track。

  • [x] PART_SelectionRange: SelectionRangeElement (FrameworkElement)
  • [x] PART_Track: TrackName (Track)

這裡需要注意的是,Track 的類型與 XAML 中的類型相同,但 SelectionRange 被定義為 FrameworkElement,而不是原來的 Rectangle,這意味着 SelectionRange 可以是任何類型的控件,而不僅僅是 Rectangle。這是有意将類型定義得更加靈活。

是以,我們可以推測 SelectionRangeElement (定義為 FrameworkElement 類型) 僅處理此類型所能處理的基本功能。

下面是實際處理 SelectionRangeElement 的部分。

private void UpdateSelectionRangeElementPositionAndSize()
{
 Size trackSize = new Size(0d, 0d);
 Size thumbSize = new Size(0d, 0d);

if (Track ==  || DoubleUtil.LessThan(SelectionEnd,SelectionStart))
 {
return;
 }

 trackSize = Track.RenderSize;
 thumbSize = (Track.Thumb != ) ? Track.Thumb.RenderSize : new Size(0d, 0d);

double range = Maximum - Minimum;
double valueToSize;

 FrameworkElement rangeElement = this.SelectionRangeElement as FrameworkElement;

if (rangeElement == )
 {
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 的邏輯(水準/垂直)實際上是相同的,是以我們隻需要關注水準方向的邏輯即可。

正是通過這個 (UpdateSelectionRangeElementPositionAndSize) 方法來決定 SelectionRange 的大小和位置。盡管源代碼的量可能看起來有些多,但考慮到 Orientation 分支邏輯的重複代碼,我們可以很容易地看出對 SelectionRange 的處理還是相對簡潔的。

這樣,我們可以通過反向查找和分析 CustomControl 控件以及

PART_

控件在内部是如何處理的。

8. 跨平台中的 OnApplyTemplate

許多跨平台架構在設計上與 WPF 有許多相似之處,是以它們在流程上也遵循類似的模式。是以,我們可以基于前面分析的 OnApplyTemplate 來看看其他平台上的實作。

共享 OnApplyTemplate 設計的平台清單:

  • [x] AvaloniaUI
  • [x] Uno Platform
  • [x] OpenSilver
  • [x] MAUI
  • [x] Xamarin
  • [ ] UWP
  • [ ] WinUI 3
  • [ ] Silverlight

在這些平台中,已勾選的 AvaloniaUI、Uno Platform、OpenSilver、MAUI 和 Xamarin 的原始源代碼值得我們進一步檢視。

值得一提的是,除了 Silverlight,這些平台的代碼庫都由 Microsoft 官方組織 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);

// 擷取元件
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();

// 擷取元件
 ...
 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 和 .NET Core 等平台的出現,這個夢想已經成為現實。這是熱愛 WPF 的衆多開發者共同努力的結果。

通過這次分析,我們了解了基本控件分析的重要性。建議大家通過教程視訊再次複習和學習這些内容。

接下來,我們将基于這次分析,建立一個新的 Riot 風格的 (CustomControl) Slider。

10. 建立 Riot 風格的 Slider (CustomControl) 控件

現在,我們将基于對Slider的分析,利用控件的特性,通過最少的設計來實作它。在這個過程中,核心是利用PART_部分而不使用任何代碼來完成控件。

動機

直接使用預設的Slider并不常見,是以我們需要靈感。恰好,我曾嘗試設計一個以Riot Games的《英雄聯盟》遊戲為設計概念的Slider,是以決定以此為控件的靈感來源。

實際上,這個設計源于幾年前我想用WPF實作一個高水準的遊戲用戶端,開始了“英雄聯盟”應用程式的開發。是以,如果你想了解這個Slider控件的實際效果,可以檢視這個倉庫。通過Fork,任何人都可以參與貢獻,目前已經有超過80次Fork記錄。

用WPF實作《英雄聯盟》風格滑塊|深入剖析

那麼現在我們開始建立一個新的 (CustomControl) Slider 控件吧。

11. 建立和啟動項目

在前面建立了DemoApp(WPF應用程式)項目後,現在是時候建立一個CustomControl庫項目了。如果你希望在DemoApp項目中繼續進行,可以跳過這次的項目建立過程。

項目建立:
  • [x] 項目名稱:SliderControl
  • [x] 項目類型:WPF CustomControl Library
  • [x] 項目版本:.NET 8.0
用WPF實作《英雄聯盟》風格滑塊|深入剖析
删除基礎檔案:
  • [x] AssemblyInfo.cs
  • [x] Themes/Generic.xaml
  • [x] CustomControl1.cs
用WPF實作《英雄聯盟》風格滑塊|深入剖析

這些被删除的檔案實際上是構成(CustomControl)控件的必需檔案,但為了重新組織項目結構或位置,我們會将它們删除。

在重新建立控件的過程中,删除的元素會自動重新生成,是以不需要擔心檔案删除的問題。
建立(CustomControl)檔案:
  • [x] 建立RiotSlider.cs (CustomControl)Class
用WPF實作《英雄聯盟》風格滑塊|深入剖析

隻有在将檔案類型設定為CustomControl類時,DefaultStyleKeyProperty相關語句才會與靜态構造函數一起包含。如果在建立過程中選擇了錯誤的類型,則會遺漏CustomControl相關代碼,需要手動輸入,是以務必仔細确認每個步驟。

public class RiotSlider : Slider
{
static RiotSlider()
 {
 DefaultStyleKeyProperty.OverrideMetadata(typeof(RiotSlider), new FrameworkPropertyMetadata(typeof(RiotSlider)));
 }
}
           
确認自動生成的檔案:
  • [x] Properties/AssemblyInfo.cs
  • [x] Themes/Generic.xaml
用WPF實作《英雄聯盟》風格滑塊|深入剖析

如果不将檔案類型設定為CustomControl類,同樣這些檔案也不會自動生成。這一點務必注意。

12. TextBlock (Hi Slider)

接下來是測試Slider控件是否已經正确配置為CustomControl格式的步驟。

初次建立(CustomControl) Slider控件時,預設會生成一個空的ControlTemplate模闆。是以,為了在視覺上确認控件,我們通常會添加一些設計元素。我們将在這裡添加一個臨時的TextBlock和文本。

添加臨時TextBlock:
  • [x] Hi Slider
<Style TargetType="{x:Type local:RiotSlider}">
 <Setter Property="Template">
 <Setter.Value> 
 <ControlTemplate TargetType="{x:Type RiotSlider}">
 <Border Background="{TemplateBinding Background}"
 BorderBrush="{TemplateBinding BorderBrush}"
 BorderThickness="{TemplateBinding BorderThickness}">
 <TextBlock Text="Hi Slider" Foreground="Blue"/>
 </Border>
 </ControlTemplate>
 </Setter.Value>
 </Setter>
</Style>
           
在空的ControlTemplate的Border中添加TextBlock和“Hi Slider”文本。也可以更改字型顔色。

13. 添加引用并測試運作

準備好測試用的TextBlock後,現在是時候運作DemoApp應用程式,檢查RiotSlider控件是否正确加載。

在DemoApp項目中添加引用:
  • [x] 添加引用:RiotSliderControl項目
在MainWindow.xaml中聲明xmlns并添加控件:
  • [x] 聲明xmlns:xmlns:riots
  • [x] 插入控件:riots:RiotSlider
<Window x:Class="DemoApp.MainWindow" 
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:riots="clr-namespace:SliderControl;assembly=SliderControl"
mc:ignorable="d"
Title="MainWindow" Width="800" Height="450">
<Grid>
<riots:RiotSlider/>
</Grid>
</Window>
           

這樣,我們就可以在DemoApp應用程式中檢視和測試我們建立的RiotSlider控件了。

檢查運作結果:
  • [x] Riot Slider: "Hi Slider"
用WPF實作《英雄聯盟》風格滑塊|深入剖析

到這裡,我們已經完成了(CustomControl) RiotSlider控件的配置,并确認它能夠正常運作。

由于CustomControl方式比UserControl方式複雜,在熟悉這個過程之前,可能會遇到一些困難。是以,需要通過重複練習來克服這些困難。

現在,這個RiotSlider控件已經子產品化為CustomControl形式,便于管理。我們可以将這個控件上傳到GitHub倉庫進行管理,或者釋出到NuGet包進行分發。WPF中的CustomControl子產品化在管理方面有很多優勢,是以在項目設計時應考慮這一點。

當然,這個項目已經通過NuGet Package商店進行釋出。挺有意思的吧?

14. 設定Riot Slider的大小

接下來,我們将設定控件的大小。

WPF提供了非常強大和靈活 (Responsive) 的響應式布局。是以,在指定控件大小時,通常會設計為響應式布局。然而,對于某些特殊控件,比如包含許多設計元素的Slider,可能需要設定固定的高度或寬度來實作自然的設計。是以,根據控件的特性靈活應對是很重要的。

這次,我們将設計一個高度為50的(Thumb)控件。是以,我們會預先指定RiotSlider的高度。此外,雖然寬度将作為Track的移動路徑實作響應式,但為了開發友善,我們會暫時把它限制為200。

調整控件尺寸和顔色:
  • [x] 寬度:200
  • [x] 高度:50
  • [x] 背景色:"#EEEEEE"
<Window x:Class="DemoApp.MainWindow" 
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression.blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:riots="clr-namespace:SliderControl;assembly=SliderControl"
mc:ignorable="d"
Title="MainWindow" Width="800" Height="450">
<Grid>
<riots:RiotSlider Width="200" Height="50" Background="#EEEEEE"/>
</Grid>
</Window>
           
為了更好地估計控件尺寸,可以臨時更改背景色,便于識别控件。這是個小技巧。
檢查運作結果:
  • [x] 檢查控件尺寸:寬度/高度
  • [x] 檢查控件顔色:背景色
用WPF實作《英雄聯盟》風格滑塊|深入剖析

在确認運作結果沒有問題後就可以移除背景色了。

15. PART_Track

Track是包含Thumb的Slider的核心控制元素。通過分析,我們可以看到通過

PART_Track

聲明,Slider控件可以處理所有這些功能。是以,在實作過程中正确地包含這個關鍵元素是非常重要的。

讓我們仔細看看。

添加Track:
  • [x] 插入PART_Track控制元素
<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Track x:Name="PART_Track"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           
Track是少數幾個直接繼承自FrameworkElement而不是Control的控件之一。這意味着它沒有像ControlTemplate那樣直接設計布局的資格。是以,它包含Thumb并直接建構布局,是以可以認為這個控件主要關注的是Thumb。
定義Thumb:

接下來,我們定義在Track上移動的Thumb。

  • [x] 擴充Thumb并定義模闆
  • [x] 實作Ellipse
<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate>
<Ellipse Width="50" Height="50" Fill="#000000"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           
這是在Track中直接擴充并實作Thumb的案例。文法上可能有些難了解,但教程視訊中有詳細的視覺解釋,建議大家觀看教學視訊。

在這個Thumb示例中,通過模闆定義控件與Track不同。這意味着Thumb繼承自Control而不是FrameworkElement,是以可以通過ControlTemplate來靈活設計控件。

檢查運作結果:
  • [x] 檢查Thumb (Ellipse) 設計
  • [x] 檢查Track移動功能
用WPF實作《英雄聯盟》風格滑塊|深入剖析

由于Thumb被設計為Ellipse形狀,是以這個大的(50x50)橢圓會在Track區域内移動。但是,如果将Track的名稱從

PART_Track

更改為其他名稱,Thumb将無法移動。

為了再次認識到這種相關性,大家可以嘗試更改一下名稱。

16. 添加滑塊條

接下來我們來添加滑塊。這一步隻是為了添加設計元素,不涉及功能。是以省略這一步,也不會影響功能。但由于接下來的步驟是 SelectionRange 階段,并需要結合設計元素,是以這一步也需要仔細進行。

修改布局:

到目前為止,Border 内隻有 Track 元素,但現在需要添加滑塊條,是以需要修改現有布局。此外,由于 Track 和滑塊條需要疊加效果,是以使用 Grid 是唯一的選擇。是以,首先将 Track 包裹在 Grid 中。

  • [x] 修改布局:使用 Grid
<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate>
<Ellipse Width="50" Height="50" Fill="#000000"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           

由于隻需要簡單的疊加效果,是以不需要使用 Grid 的 RowDefinitions 或 ColumnDefinitions。

添加與 Track 疊加的滑塊條:

滑塊條需要與 Track 疊加,但需要先邏輯上确定哪個元素在前(前端)。由于 Track 的 Thumb 控件需要覆寫滑塊條區域,是以需要先添加滑塊條,然後再聲明 Track。

  • [x] 添加:(Border)滑塊條
  • [x] 高度:2.5
  • [x] 背景色:#CCCCCC
<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Border Background="#CCCCCC" Height="2.5"/>
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate>
<Ellipse Width="50" Height="50" Fill="#000000"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           

此外,由于需要視覺上表現 Track 的長度,是以使用 Border 這樣的布局是有效的。特别是 Border 的 CornerRadius 屬性,可以處理邊角的圓角效果,相比其他控件可以表達更豐富的設計。

運作結果确認:
  • [x] 确認 Thumb 的移動: (Ellipse)
  • [x] 确認滑塊條設計: (Border)
用WPF實作《英雄聯盟》風格滑塊|深入剖析

如圖所示,滑塊條的設計和位置應與 Track 的移動路徑和 Thumb 的移動和諧一緻,這是本階段的關鍵點。

17. 調整滑塊條和 Track 之間的誤差

雖然滑塊條的設計和位置看起來不錯,但實際上 Track 的移動範圍在起始和結束處各限制了 Thumb 的半徑。檢視 WPF 的原始源碼,我們可以發現如下代碼:

Canvas.SetLeft(rangeElement, (thumbSize.Width * 0.5) + Math.Max(Maximum - SelectionEnd, 0) * valueToSize);
           
上述代碼是針對 Orientation="Horizontal" 的情況。是以,如果值變為垂直方向,則需要更改為 Height。

從這段代碼中我們可以推斷,Track 的實際移動範圍也在内部被 ThumbSize 的半徑限制了。是以,我們之前添加的滑塊條由于不是 Slider 控件内部管理的

PART_

元素,是以需要手動應用這一規則。雖然可以通過動态方法處理,但在此步驟中我們通過 Margin 屬性來精确調整滑塊條和 Track 移動範圍之間的誤差。

設定 Thumb Ellipse 的透明度:

為了更友善地工作,指定 Ellipse 控件的透明度。

  • [x] Ellipse 填充顔色: #55000000
<Ellipse Width="50" Height="50" Fill="#55000000"/>
           
在 WPF 中,設定元素透明度時通常使用對象的透明度屬性 Opacity,但通過使用顔色的 Alpha 值,可以隻對特定顔色應用透明度。這是 WPF 的一個小技巧,非常值得利用。
應用滑塊條的 Margin 以考慮 Thumb 的半徑:

目前 Ellipse 的寬度為 50,是以左右各應用 25 的 Margin。

  • [x] Margin="25 0 25 0"
<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Border Background="#CCCCCC" Height="2.5" Margin="25 0 25 0"/>
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate>
<Ellipse Width="50" Height="50" Fill="#55000000"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           
結果确認:
  • [x] 确認 Thumb 半徑和 Margin尺寸
用WPF實作《英雄聯盟》風格滑塊|深入剖析

如圖所示,可以看到 Track 的最大移動範圍和滑塊條的設計尺寸完全一緻。

當然之後也可以再考慮考慮這次Sync 操作動态處理。目前想到的一種方法是将這個滑塊條控件指定為

PART_

,然後在 CodeBehind 中處理。當然還有其他各種方法,值得思考一下。

18. PART_SelectionRange

SelectionRange 是通過之前的 Slider 分析,負責指定特定 Range 範圍的元素。

這個控件也和 Track 一樣是

PART_

元素,Slider 控件内部處理其所有功能,是以隻需按照約定的名稱正确放置即可。由于設計上與滑塊條高度一緻,是以高度應與之前添加的滑塊條相同。

添加 SelectionRange Border 區域:
  • [x] 名稱:

    PART_SelectionRange

  • [x] 高度: 2.5
  • [x] 背景色: #000000
  • [x] Margin: 25 0 25 0
<Border x:Name="PART_SelectionRange" 
Background="#000000" 
Height="2.5"
Margin="25 0 25 0"/>
           
指定 Range 範圍:

通過 RelativeSource Binding 将 SelectionEnd 的範圍與 Value 值同步。

  • [x] SelectionStart: 0
  • [x] SelectionEnd: {Binding RelativeSource {RelativeSource Self}, Path=Value}
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
           

通過将 SelectionEnd 的值與 Value 值同步,可以動态表達 Range 範圍。實際上,英雄聯盟用戶端應用程式的 Slider 控件也是以相同的方式實作的。

完整代碼:

将以上步驟綜合,得到完整代碼如下:

<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Border x:Name="PART_SelectionRange" 
Background="#000000" 
Height="2.5"
Margin="25 0 25 0"/>
<Border Background="#CCCCCC" Height="2.5" Margin="25 0 25 0"/>
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate>
<Ellipse Width="50" Height="50" Fill="#55000000"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           

這樣,我們就完成了 SelectionRange 的添加和滑塊條與 Track 的同步處理。

處理 IsSelectionRangeEnabled 啟用:

考慮到 Riot Slider 控件的概念,可能不需要處理這個功能。但由于可以通過觸發器簡單地實作,我們可以在學習的過程中嘗試一下。

教程視訊中未涉及此部分内容。
  • [x] IsSelectionRangeEnabled: True
<Setter Property="IsSelectionRangeEnabled" Value="True"/>
           
将 IsSelectionRangeEnabled 屬性的預設值設定為 True。
  • [x] PART_SelectionRange Visibility: (預設) Collapsed
<Border x:Name="PART_SelectionRange" 
Background="#000000" 
Height="2.5"
Margin="25 0 25 0"
Visibility="Collapsed"/>
           
将 SelectionRange 的預設 Visibility 設定為 Collapsed。
  • [x] 觸發器: PART_SelectionRange.Visibility=Visible
<Trigger Property="IsSelectionRangeEnabled" Value="True">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible"/>
</Trigger>
           
将 SelectionRange 的預設 Visibility 設定為 Collapsed,當 IsSelectionRangeEnabled 屬性的值為 True 時,通過觸發器将 Visibility 設定為 Visible。雖然可以反向設定,但在觸發器中檢查 Boolean 屬性的 True 值更符合正常代碼習慣。
源代碼及運作結果确認:
  • [x] 應用 Setter
  • [x] SelectionRange (預設) Collapsed
  • [x] 應用觸發器 IsSelectionRangeEnabled
<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="IsSelectionRangeEnabled" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Border Background="#CCCCCC" Height="2.5" Margin="25 0 25 0"/>
<Border x:Name="PART_SelectionRange" 
Background="#000000" 
Height="2.5"
Margin="25 0 25 0"
HorizontalAlignment="Left"
Visibility="Collapsed"/>
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate>
<Ellipse Width="50" Height="50" Fill="#55000000"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelectionRangeEnabled" Value="True">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           
用WPF實作《英雄聯盟》風格滑塊|深入剖析

現在,我們已經添加了構成 Slider 功能的所有元素。接下來,我們将再次檢查

PART_

控件元素的功能,以完成此步驟,并進入下一個階段。

再次确認 PART_ 控件功能:
  • [x] PART_Track
  • [x] PART_SelectionRange

19. 添加 Riot 風格的設計元素

接下來是為 Riot Slider 添加所需的設計元素。

用WPF實作《英雄聯盟》風格滑塊|深入剖析
添加 Geometry 設計資源:
  • [x] Geometry: ThumbData
<Geometry x:Key="ThumbData">
 M12 2C11.5 2 11 2.19 10.59 2.59L2.59 10.59C1.8 11.37 1.8 12.63 2.59 13.41L10.59 21.41C11.37 22.2 12.63 22.2 13.41 21.41L21.41 13.41C22.2 12.63 22.2 11.37 21.41 10.59L13.41 2.59C13 2.19 12.5 2 12 2M12 4L15.29 7.29L12 10.59L8.71 7.29L12 4M7.29 8.71L10.59 12L7.29 15.29L4 12L7.29 8.71M16.71 8.71L20 12L16.71 15.29L13.41 12L16.71 8.71M12 13.41L15.29 16.71L12 20L8.71 16.71L12 13.41Z
</Geometry>
           

我們使用 Geometry Path 元素來繪制 Thumb 圖示,而不是圖像檔案,這是因為它具有以下優點:可以通過顔色觸發器自由更改顔色,并保持基于矢量的高品質。

這種簡單的圖示,非設計師也可以通過 Visual Studio Blend 或 Figma、Illustrator 等工具輕松制作。不難,值得一試。

向同僚請求矢量圖示時,建議使用 SVG 格式,并要求單色設計的圖示為組合形态。此外,還有很多開源圖示可免費使用。例如 Pictogrammers 提供超過 8000 個單色設計圖示,包括

.SVG

.PNG

.XAML

格式。并且通過 GitHub 開源管理,可以檢視主要貢獻者或參與開源項目。

接下來是添加主要顔色資源。

添加 LinearGradientBrush 設計資源:
  • [x] LinearGradientBrush: ThumbColor
  • [x] LinearGradientBrush: ThumbOver
  • [x] LinearGradientBrush: ThumbDrag
  • [x] SolidColorBrush: SliderColor
  • [x] LinearGradientBrush: RangeColor
  • [x] LinearGradientBrush: SliderOver
  • [x] LinearGradientBrush: SliderDrag
<LinearGradientBrush x:Key="ThumbColor" StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Color="#B79248" Offset="0"/>
<GradientStop Color="#997530" Offset="0.5"/>
<GradientStop Color="#74592B" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="ThumbOver" StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Color="#EDE1C8" Offset="0"/>
<GradientStop Color="#DCC088" Offset="0.5"/>
<GradientStop Color="#CBA14A" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="ThumbDrag" StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Color="#473814" Offset="0"/>
<GradientStop Color="#57421B" Offset="0.5"/>
<GradientStop Color="#684E23" Offset="1"/>
</LinearGradientBrush>

<SolidColorBrush x:Key="SliderColor" Color="#1E2328"/>

<LinearGradientBrush x:Key="RangeColor" StartPoint="0,0.5" EndPoint="1,0.5">
<GradientStop Color="#463714" Offset="0"/>
<GradientStop Color="#58471D" Offset="0.5"/>
<GradientStop Color="#695625" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="SliderOver" StartPoint="0,0.5" EndPoint="1,0.5">
<GradientStop Color="#795B28" Offset="0"/>
<GradientStop Color="#C1963B" Offset="0.5"/>
<GradientStop Color="#C8AA6D" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="SliderDrag" StartPoint="0,0.5" EndPoint="1,0.5">
<GradientStop Color="#685524" Offset="0"/>
<GradientStop Color="#55441B" Offset="0.5"/>
<GradientStop Color="#463714" Offset="1"/>
</LinearGradientBrush>
           
顔色等設計資源的 x:Key 規則通常有大寫或駝峰命名法,以及類似命名空間的方式。個人建議盡量簡短。雖然每年看法有所變化,但目前傾向于盡量簡短。

仔細觀察《英雄聯盟》的設計風格,可以發現大量使用漸變色。提取這些顔色的方法是使用 Photoshop 或帶有吸管顔色提取功能的應用程式。

對于可能存在漸變色的區域,可以多次使用吸管功能提取顔色。多加練習後,眼力也會變得敏銳。
用WPF實作《英雄聯盟》風格滑塊|深入剖析

20. 實作 Riot 風格的 Thumb

現在,我們将使用準備好的 Geometry 和設計元素,正式建立一個符合《英雄聯盟》風格的 Thumb 控件。

在開始之前,我們需要先删除之前定義的臨時 Ellipse 作為 Thumb 的模闆。是以,删除所有包含 Ellipse 的 Thumb 定義部分。

删除現有 Thumb:
  • [x] 删除 Thumb 及其模闆
<Track x:Name="PART_Track">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate>
<Ellipse Width="50" Height="50" Fill="#55000000"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
           
删除直接在 Track 内定義的 Thumb 和模闆,隻保留 Track。

現在是建立 Riot 風格新 Thumb 的時候了。

剛才删除的 Thumb 是通過擴充 Track 直接定義的臨時模闆,但這次我們将通過 StaticResource 進行整潔的資源管理來實作它。

定義新的 Thumb 模闆:
  • [x] 實作 Riot 風格的 Thumb 并細化資源
<Style TargetType="{x:Type Thumb}" x:Key="ThumbStyle">
<Setter Property="Background" Value="#010A13"/>
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="24"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Thumb}">
<Grid Background="{TemplateBinding Background}">
<Path x:Name="path" Data="{StaticResource ThumbData}" Fill="{StaticResource ThumbColor}"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="path" Property="Fill" Value="{StaticResource ThumbOver}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="path" Property="Fill" Value="{StaticResource ThumbDrag}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
           
在 CustomControl 基礎上,XAML 資源管理實際上非常簡單。由于通過 Generic.xaml 已經實體分離了資源,是以可以繼續通過 x:Key 進一步細化管理。正因如此,我們也将 Geometry 和 LinearGradientBrush 分離出來。這些資源隻需要包含在 RiotSlider 控件的樣式檔案中即可。

Thumb 作為從 Control 繼承的控件,可以通過模闆(ControlTemplate)進行設計。是以,可以建立一個詳細觸發器實作的控件。此外,如果想要建立更詳細的控件,還可以通過 CustomControl 方式進一步細化 Thumb。這種情況在 WPF 基本控件中非常常見。

探索一些知識。例如,像 ToolBarOverflowPanel 這樣的控件,盡管聽起來比較陌生,但實際上有很多。這些都是在 CustomControl 基礎上需要更細化控件時建立的,通常歸類在 Primitives 命名空間中。

是以,歸屬于 Primitives 命名空間的控件,通常是包含在其他(CustomControl)控件中的。以 Primitives 的代表控件 ToggleButton 為例,它不僅是 CheckBox/RadioButton 的父控件,還可以在 ComboBox 等控件的模闆中用作切換項的控件。

挺有意思的吧?這些架構概念适用于所有共享 XAML 的跨平台技術。是以,能夠靈活應用這些概念,在 AvaloniaUI、Uno、MAUI 等環境中也會大有幫助。
當然,歸屬 Primitives 命名空間的控件不一定都通過 DefaultStyleKey 指定為 CustomControl。其中也有很多隻是簡單封裝的類。

21. 聲明 Thumb 資源

最後,将 Thumb 以資源形式聲明,以便在 Track 中通過 StaticResource 使用。

添加 Thumb 資源:
  • [x] 将包含模闆的 Thumb 樣式與 Thumb 資源連接配接定義
<Thumb x:Key="SliderThumb" Style="{StaticResource ThumbStyle}"/>
           
教程視訊中詳細介紹了這部分内容,如果在文法覺得不是很自然,建議可以參考視訊。

現在,隻需在 Track 中使用資源化的 Thumb。

在 Track 中簡潔定義 Thumb:
  • [x] 使用 StaticResource 連接配接替換現有 Thumb
<Track Thumb="{StaticResource SliderThumb}"/>
           
使用 Resource 形式的 Thumb 可以大大減少在 Track 中定義 Thumb 時的代碼量。此外,通過這種方式管理資源,有助于整體上更清晰地掌握資源,是保持代碼品質的重要方法之一。是以需要認真學習這種管理方式。

繼續下一個步驟:檢查所有

PART_

控件功能是否正常。

再次确認

PART_

控件功能:
  • [x] PART_Track
  • [x] PART_SelectionRange

22. 完成 RiotSlider 模闆 (收尾)

現在我們将完成 RiotSlider 控件的模闆實作。除此之外,還包含了 Jamesnet.WPF 庫,是以我們使用了 JamesGrid,普通的 Grid 也是可以替代的。

(CustomControl) RiotSlider:
  • [x] 檢視 Generic.xaml 的完整源代碼
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:james="https://jamesnet.dev/xaml/presentation"
xmlns:local="clr-namespace:SliderControl">

<Geometry x:Key="ThumbData">
 M12 2C11.5 2 11 2.19 10.59 2.59L2.59 10.59C1.8 11.37 1.8 12.63 2.59 13.41L10.59 21.41C11.37 22.2 12.63 22.2 13.41 21.41L21.41 13.41C22.2 12.63 22.2 11.37 21.41 10.59L13.41 2.59C13 2.19 12.5 2 12 2M12 4L15.29 7.29L12 10.59L8.71 7.29L12 4M7.29 8.71L10.59 12L7.29 15.29L4 12L7.29 8.71M16.71 8.71L20 12L16.71 15.29L13.41 12L16.71 8.71M12 13.41L15.29 16.71L12 20L8.71 16.71L12 13.41Z
</Geometry>

<LinearGradientBrush x:Key="ThumbColor" StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Color="#B79248" Offset="0"/>
<GradientStop Color="#997530" Offset="0.5"/>
<GradientStop Color="#74592B" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="ThumbOver" StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Color="#EDE1C8" Offset="0"/>
<GradientStop Color="#DCC088" Offset="0.5"/>
<GradientStop Color="#CBA14A" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="ThumbDrag" StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Color="#473814" Offset="0"/>
<GradientStop Color="#57421B" Offset="0.5"/>
<GradientStop Color="#684E23" Offset="1"/>
</LinearGradientBrush>

<Style TargetType="{x:Type Thumb}" x:Key="ThumbStyle">
<Setter Property="Background" Value="#010A13"/>
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="24"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Thumb}">
<Grid Background="{TemplateBinding Background}">
<Path x:Name="path" Data="{StaticResource ThumbData}" Fill="{StaticResource ThumbColor}"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="path" Property="Fill" Value="{StaticResource ThumbOver}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="path" Property="Fill" Value="{StaticResource ThumbDrag}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

<Thumb x:Key="SliderThumb" Style="{StaticResource ThumbStyle}"/>

<SolidColorBrush x:Key="SliderColor" Color="#1E2328"/>

<LinearGradientBrush x:Key="RangeColor" StartPoint="0,0.5" EndPoint="1,0.5">
<GradientStop Color="#463714" Offset="0"/>
<GradientStop Color="#58471D" Offset="0.5"/>
<GradientStop Color="#695625" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="SliderOver" StartPoint="0,0.5" EndPoint="1,0.5">
<GradientStop Color="#795B28" Offset="0"/>
<GradientStop Color="#C1963B" Offset="0.5"/>
<GradientStop Color="#C8AA6D" Offset="1"/>
</LinearGradientBrush>

<LinearGradientBrush x:Key="SliderDrag" StartPoint="0,0.5" EndPoint="1,0.5">
<GradientStop Color="#685524" Offset="0"/>
<GradientStop Color="#55441B" Offset="0.5"/>
<GradientStop Color="#463714" Offset="1"/>
</LinearGradientBrush>

<Style TargetType="{x:Type local:RiotSlider}">
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RiotSlider}">
<Grid Background="{TemplateBinding Background}">
<james:JamesGrid Rows="*" Columns="Auto,*" Height="2.5" Margin="12 0 12 0">
<Border Background="{StaticResource RangeColor}" x:Name="PART_SelectionRange"/>
<Border Background="{StaticResource SliderColor}"/>
</james:JamesGrid>
<Track x:Name="PART_Track" Thumb="{StaticResource SliderThumb}"/>
</Grid>
<ControlTemplate.Triggers>
<DataTrigger Binding="{Binding ElementName=PART_Track, Path=Thumb.IsMouseOver}" Value="True">
<Setter TargetName="PART_SelectionRange" Property="Background" Value="{StaticResource SliderOver}"/>
</DataTrigger>
<DataTrigger Binding="{Binding ElementName=PART_Track, Path=Thumb.IsDragging}" Value="True">
<Setter TargetName="PART_SelectionRange" Property="Background" Value="{StaticResource SliderDrag}"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
           
此外,還添加了兩個觸發器,并将 RiotSlider 控件的 (ControlTemplate) 模闆區域細分為資源,以便一目了然地管理所有元素,這是這個項目的特點。

由于 Slider 控件是基于 (CustomControl) 實作的,是以可以像管理資源包一樣管理相關資源。

确認最終結果:
  • [x] 測試

    PART_Track

    相關功能
  • [x] 測試

    PART_SelectionRange

    相關功能
  • [x] 确認應用設計元素
雖然功能部分已經經過多個階段的分析和實作,但還是以

PART_

控件為标準再檢查一次功能。
用WPF實作《英雄聯盟》風格滑塊|深入剖析

至此,從分析基礎 Slider 控件到實作《英雄聯盟》風格的 RiotSlider 控件,基于 (CustomControl) 的開發過程的詳解及教程視訊的回顧就完成了。

23. 最後的話

我們從架構的角度深入探讨了WPF Slider 控件。表面上看似簡單,但實際上有很多值得讨論的地方,這也說明了通過 WPF 在設計方面可以學習到很多内容。 也非常建議大家觀看我們的教程視訊,内容中我們展示編碼的同時也進行了詳細的講解。

WPF 是一個有些曆史的平台,是以在漫長的歲月裡,各種開發方法論、架構群組件開源庫不斷發展和變化。随着時間的推移,主流的評價和解釋也會不斷變化。是以,迄今為止積累的曆史經驗實際上都可以成為我們技術的基石。如果能夠靈活地判斷和評估這些曆史,我們就能找到更多豐富且優質的參考資料。主流的觀點并不一定總是正确的。

這是我們久違地用心撰寫的一篇長篇回顧,希望能傳遞給更多人。

謝謝!