65.9K
CodeProject 正在变化。 阅读更多。
Home

分析和自定义 WPF Slider 控件的详细机制

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2024年2月14日

CPOL

31分钟阅读

viewsIcon

6666

本次对WPF Slider控件的探索旨在深入了解WPF如何设计其控件及其内部机制。虽然由于源代码庞大,几乎不可能深入研究每个WPF控件的内部细节,但无需感到紧迫或抱怨。

引言

在WPF中,像ButtonToggleButton这样的基本控件在结构和逻辑上都很简单,可以使用XAML完全实现,无需代码隐藏。相比之下,像TextBoxeComboBoxSlider这样的复杂控件则需要复杂的C#代码和XAML来实现其功能。

理解和应用WPF控件的复杂配置,可以带来更优雅、更灵活的CustomControl设计和开发。熟练掌握这些基本组件,可以弥补MVVM开发模式中的不足,从而创建高质量的WPF应用程序。

本次对WPF Slider控件的探索旨在深入了解WPF如何设计其控件及其内部机制。虽然由于源代码庞大,几乎不可能深入研究每个WPF控件的内部细节,但无需感到紧迫或抱怨。

WPF的全部源代码在GitHub上公开可用并进行管理。这种可访问性意味着您可以随时查找和分析特定控件,而无需着急。尽管可能会感到疲惫,但无需抱怨。

除了Slider控件之外,我们还计划剖析和分析更多复杂多样的控件。我们非常感谢您通过我们的GitHub存储库、CodeProject以及YouTube和BiliBili上的教程视频提供的支持、兴趣和帮助,以支持未来的教程。

目录

  1. WPF教程系列
  2. Specification
  3. 创建应用程序项目
  4. 分析Slider的主要功能
  5. 提取原始样式过程
  6. 提取源代码分析
  7. 检查代码隐藏(GitHub开源)
  8. 跨平台中的OnApplyTemplate
  9. 总结Slider分析
  10. 创建Riot风格的Slider(CustomControl)控件
  11. 项目创建和准备工作
  12. TextBlock(你好Slider)
  13. 添加引用和测试执行
  14. 设置Riot Slider的尺寸
  15. PART_Track
  16. 添加Slider条
  17. 对齐Slider条和Track之间的间隙
  18. PART_SelectionRange
  19. 添加Riot风格设计元素
  20. 实现Riot风格的Thumb
  21. 声明Thumb资源
  22. 完成RiotSlider模板(收尾工作)
  23. 最后的 remarks

1. WPF教程系列

迄今为止,我们已经在YouTube和BiliBili上发布了四个教程系列。这些视频有英语和中文版本,YouTube上还有韩语字幕。我们希望通过精炼的源代码和详细的专家讲解,这些视频能加深您对WPF的理解。

2. 规格

本项目基于.NET Core,但由于使用了WPF,仅限Windows平台。它可以通过VS2022运行,运行NET 8.0必须使用VS2022。或者,也可以使用JetBrains Rider。

  • 操作系统:Microsoft Windows 11
  • IDE:Microsoft Visual Studio 2022
  • 版本:C# / NET 8.0 / WPF / 仅限Windows目标
  • NuGet:Jamesnet.Wpf

建议使用最新版本的Windows作为您的操作系统。但是,如果您考虑平台扩展到Avalonia UI、Uno Platform、MAUI等,也可以考虑将macOS作为子设备。我们也使用Thinkpad/Macbooks。请注意,Visual Studio在macOS或基于Linux的系统上不可用,因此Rider是唯一的替代方案。vscode

3. 创建应用程序项目

要开始,您首先需要创建一个WPF应用程序项目。

  • 项目类型:WPF应用程序
  • 项目名称:DemoApp
  • 项目版本:.NET 8.0

4. 分析Slider的主要功能

WPF的Slider控件,不像Button那样简单的控件,有很多属性。这些属性在控件中起着关键的功能作用,有些属性的运作方式很独特,因此特别值得关注。

Orientation

WPF中的控件通常具有多功能性,Slider控件的Orientation属性就是一个很好的例子。此属性允许指定方向为水平或垂直。

Orientation属性也可以在StackPanel控件中找到。虽然StackPanelOrientation的默认值是Vertical,但SliderOrientation默认值为Horizontal。因此,通常使用Horizontal格式的Slider,这可能是Orientation功能不广为人知的原因。

让我们更仔细地看看Slider的简化部分,以便更好地理解Orientation

<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>

您可以看到,在触发器中,(ControlTemplate)模板会根据Orientation属性进行切换。因此,仔细查看此控件的实际配置可以轻松说明Orientation属性的重要作用。

这是一个有趣的部分。在看到原始源代码之前,您能想到或应用通过Orientation切换模板的概念吗?开源可以这样启发。而且,让我们注意到切换模板的最佳时机确实是通过“Style.Trigger”。

对于本教程视频,我们将只实现Horizontal方向,因此不会通过Orientation进行任何分支切换。但是,我们鼓励您尝试创建Vertical版本,并通过Fork提交Pull Request。将其视为一项任务。

我们还来看看Horizontal/Vertical属性是如何应用的。

  • 方向:水平

下面将讨论的SelectionRange(蓝色)区域也可见。

  • 方向:垂直

同样,您会发现相当多的控件以类似的方式切换(ControlTemplate)模板本身(例如,ScrollViewer)。

最小值、最大值和当前值

这些是双精度类型属性,分别表示最小范围、最大范围和当前值。在内部,控件的大小和比例会根据这些值自动计算RangeValue的位置。

由于所有这些属性都是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"/>

令人惊讶的是,通过BindingValue链接到SelectionEnd,可以随着值的变化而动态改变SelectionRange)。这是WPF开发人员的本意吗?令人印象深刻,而且简洁的实现方法非常令人满意。

这将在本文后面讨论的Riot风格Slider(CustomControl)的实现中发挥关键作用,所以请牢记。

5. 提取原始样式过程

如前所述,由于WPF通过GitHub存储库进行开源管理,因此可以检查所有控件的源代码。然而,考虑到存储库包含解决方案、所有项目和文件,提取特定控件部分的内容几乎是不可能的。

幸运的是,Visual Studio提供了一个GUI功能,用于提取特定控件的默认样式(Template)。因此,无需筛选开源代码,您可以轻松地提取相关代码。

将其视为Blazor中的Identity脚手架(虽然性质略有不同,但有助于理解)是可以的。

此外,通过Visual Studio提取原始样式会将您链接到实际可修改的资源形式,从而可以立即自定义设计和功能。因此,由于不仅是Slider,所有控件都可以提取原始样式和模板,这对于WPF研究/学习来说是一个非常有价值的元素。

如果您查看Infragistics、Syncfusion、ArticPro等商业组件,并非所有组件都提供此提取功能。每个公司都有其披露范围和政策,大多数公司更喜欢通过DataTemplate进行模块化以进行自定义,而不是暴露ControlTemplate。查看您正在使用的组件很有趣。

提取方法和步骤:Visual Studio
  • 提取默认控件(Slider)样式(编辑副本...)
  • 提取到当前文件(本文档)
  • 提取到App.xaml文件(应用程序)
  • 为提取创建一个新的ResourceDictionary文件(资源字典)

注意,提取过程只能在PartialUserControl的设计区域进行,通过选择控件并右键单击来继续。此步骤包括选择“指定样式名称/定义提取样式的副本位置”选项。

尝试在VScode或Rider中查找方法,它们是否提供此功能?

让我们更仔细地看一下过程。

  • 样式提取命令:Slider > **右键单击** > **编辑模板** > **编辑副本**...

如果没有提供可提取的样式,此项将不会激活。

  • 样式提取选项窗口:创建ControlTemplate资源(窗口)

选择名称(键)并在选项中定义,

通常,从测试和管理的角度来看,指定Name是正确的选择。如果未指定名称而选择“全部应用”,则基于定义位置创建的样式将全局应用。因此,请充分理解这一点并谨慎进行提取。

在视频中,设置了名称,并将定义位置指定为应用程序。因此,提取的资源包含在App.xaml文件的Resources区域(如果文件存在)。

个人而言,在执行此类提取工作时,建议在新项目中进行测试。实际上,在现有项目中执行此过程可能会导致小错误和问题,因此从防止此类副作用的角度来看,这是一个不错的选择。

6. 提取源代码分析

正如教程视频中所示,Slider控件样式已成功提取。让我们查看App.xaml文件中的相关资源,并逐一检查重要的元素。

检查方向分支

在前面解释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设置为SliderHorizontalControlTemplate)模板,并通过触发器,当Orientation属性值为Vertical时,切换到SliderVerticalControlTemplate)模板。

通过像这样模块化(ControlTemplate)模板,您可以获得一目了然地查看实际样式的优点,这是一种值得尝试的管理结构,即使在不切换的情况下也是如此。我经常这样做。您也可以从中获得灵感。

因此,Slider控件的功能基本上是在SliderHorizontalSliderVerticalControlTemplate)区域内实现的。

现在让我们检查默认的SliderHorizontalControlTemplate)模板。

检查ControlTemplate

让我们检查App.xaml文件中连续出现的Horizontal/Vertical特定模板。

  • 检查Horizontal特定模板
  • 检查Vertical特定模板

ControlTemplateSliderHorizontal

<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的源代码都是分支并单独实现的。因此,实现的内容两者相同,仅设计方向不同。

让我们精确验证一下。包含的共同元素如下:

  • 名称:TopTick
  • 名称:BottomTick
  • 名称:TrackBackground
  • 名称PART_SelectionRange
  • 名称PART_Track
  • 名称:Thumb
  • 触发器:TickPlacement
  • 触发器:IsSelectionRangeEnabled
  • 触发器:IsKeyboardFocused

我们可以看到共同的元素包含在两个ControlTemplates中,确认它们具有相同的组成。现在,让我们只关注并检查SliderHorizontal部分。

命名规则:PART_

在(CustomControl)控件的结构中,保持XAML和Code-behind之间的紧密连接至关重要。然而,通过GetTemplateChild方法连接以查找控件名称在视觉上可能不美观。为了缓解这种开发方法并系统地管理,使用了PART_命名规则。

此规则将通过GetTemplateChild找到的所有控件名称都加上PART_前缀,使您可以在XAML中猜测其功能。因此,在分析(ControlTemplate)控件时,发现名称以PART_开头的控件表明它很可能是必需元素,并且您可以预见到如果删除它可能产生的副作用。

最终,这对于实现CustomControls非常有帮助。此外,此规则不仅在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区域。

现在,当您运行应用程序时,拖动TrackThumb将不再能够左右移动它,如教程视频所示。Thumb不再移动的原因是,故意的名称更改阻止了Code-behind通过GetTemplateChild找到PART_Track控件。

由于找不到PART_Track控件,因此没有目标供鼠标拖动。将名称恢复为PART_Track1将恢复功能。

这种现象可以在许多其他标准控件中观察到,特别是TextBox的PART_ContentHost。

测试:故意更改PART_SelectionRange名称后的影响

接下来,让我们故意更改PART_SelectionRange控件的名称。

<Rectangle x:Name="PART_SelectionRange1" .../>

确保您在正确的Sliderhorizontal区域(x2)。

如果查看触发器部分, there are more parts usingPART_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来激活Range区域。

  • 名称更改前:PART_SelectionRange

更改前,您可以看到Range区域正常显示。

  • 名称更改后:PART_SelectionRange1

现在,Range区域不再显示。

同样,由于控件PART_SelectionRange在内部找不到,因此没有目标用于计算Range区域。

因此,WPF控件的实现比预期的要宽松,但形成了模块化结构。利用这些特性可以有效地利用已实现的现有功能或排除不必要的功能。

7. 检查代码隐藏(GitHub开源)

在详细了解了PART_控件命名规则及其影响之后,现在是时候探索这些控件在实际类中是如何使用的了。

代码隐藏(类)区域无法通过提取进一步检查。因此,有必要通过WPF存储库查看官方源代码。为了进行更详细的检查,建议观看教程视频。

在实际源代码中,每个PART_控件的名称被约定为如下的string

private const string TrackName = "PART_Track";
private const string SelectionRangeElementName = "PART_SelectionRange";
名称是固定定义的,强调了遵守此命名规则的重要性。
WPF:OnApplyTemplate

让我们检查一下从(ControlTemplate)模板中检索TrackSelectionRange的部分。

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的最佳时机。

在查看原始源代码后,它们分别被定义为FrameworkElementTrack

  • PART_SelectionRangeSelectionRangeElementFrameworkElement
  • PART_TrackTrackNameTrack

值得注意的是,虽然Track与XAML中的类型相同,但SelectionRange被定义为FrameworkElement,而不是原始的Rectangle。这意味着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
  • Silverlight

其中,让我们检查一下AvaloniaUI、Uno Platform、OpenSilver、MAUI和Xamarin的实际源代码。

请注意,除了Silverlight之外,所有平台都通过GitHub上的官方Dotnet或Xamarin Microsoft组织进行管理,因此很容易在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。
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不同,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已死。然而,WPF仍然充满活力,并继续保持其地位。深入研究WPF会带来无限的可能性和兴奋。

如果曾经梦想着用WPF开发一切只是幻想,那么Xamarin和.NET Core的出现,以及各种其他平台的出现,已经将它变成了现实。这是许多热爱WPF的开发者的愿望和贡献的结果。

我们详细介绍了分析基本控件为何如此重要。建议回顾教程视频以巩固和学习其中的解释。

接下来,我们将根据此分析创建一个新的Riot风格(CustomControlSlider

10. 创建Riot风格的Slider(CustomControl)控件

现在,我们将利用对Slider的分析,以最小限度地设计和实现一个能够捕捉其精髓的控件。该项目的核心是利用PART_部分,在不使用任何代码的情况下完成控件。

通过密切遵循实现过程和顺序来专注于理解内容。如果您希望加深对CustomControl的理解,建议通过《WPF Inside Out》一书进行深入学习。

动机

几乎没有人会直接使用基本的Slider。为了获得灵感,我选择了一个基于Riot Games的《英雄联盟》设计概念的Slider,这是我创建此类控件的经验。

实际上,这个设计几年前就开始了,源于对用WPF为“英雄联盟”实现高端游戏客户端的好奇心。如果您有兴趣了解这个Slider控件的实际工作原理,请查看此存储库。此外,任何人都可以通过Fork做出贡献,该项目目前已有超过80个Fork。

11. 项目创建和准备

在创建了我们的DemoApp(WPF应用程序)项目之后,现在是时候创建一个CustomControl库项目了。如果您希望继续使用DemoApp项目,则可以跳过此项目创建步骤。

项目创建
  • 项目名称:SliderControl
  • 项目类型:WPFCustomControl
  • 项目版本:.NET 8.0

删除默认文件
  • AssemblyInfo.cs
  • Themes/Generic.xaml
  • CustomControl1.cs

所有被删除的文件实际上对于配置(CustomControl)控件至关重要,但为了重新配置位置或项目设置而被移除。

重新创建控件过程中被删除的元素将自动重新生成,因此无需担心文件删除。

(CustomControl)文件创建
  • 创建类:RiotSlider.csCustomControl类)

只有当文件被创建为CustomControl类类型时,相关的DefaultStyleKeyProperty语法才会包含在static构造函数中。在创建过程中仔细选择正确的类型至关重要,以避免丢失CustomControl代码语法,否则需要手动输入。

public class RiotSlider : Slider
{
    static RiotSlider()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(RiotSlider), 
                    new FrameworkPropertyMetadata(typeof(RiotSlider)));
    }
}
检查自动生成的文件
  • Properties/AssemblyInfo.cs
  • Themes/Generic.xaml

请注意,如果文件未被创建为CustomControl类类型,这些文件将不会被自动生成。这是一个重要的注意事项。

12. TextBlock(你好Slider)

此步骤是一个测试,以确保Slider控件已正确配置为CustomControl

首次创建(CustomControlSlider控件时,它会有一个空的ControlTemplate。为了在视觉上验证它,添加设计元素是一种常见的方法。因此,让我们添加一个带有文本的临时TextBlock

添加临时TextBlock
  • 你好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中添加“你好Slider”文本和TextBlock。可选地,更改字体颜色也可以起到画龙点睛的作用。欢迎尝试不同的方法。

13. 添加引用和测试执行

准备好用于测试的TextBlock后,现在是时候运行DemoApp应用程序来验证RiotSlider控件是否正确加载了。

在DemoApp项目中添加引用
  • 添加引用:RiotSliderControl项目
声明xmlns并在MainWindow.xaml中添加控件
  • 声明xmlns:xmlns:riots
  • 插入控件: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>
检查执行结果
  • Riot Slider:“你好Slider

至此,我们已完成了(CustomControlRiotSlider控件的设置和执行验证。

CustomControlUserControl复杂,因此在您习惯了这个过程之前可能会很困难。因此,克服这一点需要反复练习。

这个RiotSlider现在作为CustomControl进行模块化管理。您可以在GitHub存储库中管理此控件,或通过NuGet包存储进行上传和分发。WPF中CustomControl的模块化可以带来许多管理优势,因此在设计项目时考虑这一点是很好的。

而且,这个项目已经通过NuGet包存储进行分发。有趣吧?

14. 设置Riot Slider尺寸

接下来,是时候设置控件尺寸了。

WPF允许强大而灵活的(响应式)响应式布局。因此,在指定控件尺寸时,通常设计成响应式的。然而,也有例外。当Slider等设计元素大量参与时,您可能需要设置固定高度或宽度以保持自然设计,这需要指定绝对尺寸。因此,根据控件的特性灵活适应非常重要。

该控件将以50(Thumb)的高度为标准进行设计。因此,我们将预先指定RiotSlider的高度。width虽然作为Track移动的路径是响应式的,但在开发阶段为了方便,宽度将限制在200

控件尺寸和颜色调整

  • Width200
  • Height50
  • Background:“#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>

临时更改背景颜色并调整控件尺寸可以更容易地识别控件。这是一个有用的技巧。

检查执行结果
  • 控件尺寸:Width/Height
  • 控件颜色:Background

验证执行结果无误后,让我们移除Background颜色。

15. PART_Track

Track,包括Thumb,是Slider的核心控件元素。通过分析,我们已经看到Slider控件通过声明PART_Track来处理所有这些功能。因此,适当地整合这个必需的元素是这次实现的关键和中心时刻。

让我们仔细研究一下。

添加Track
  • 插入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是少数直接继承自FrameworkElementElement的控件之一,绕过了Control。这意味着它无权进行像Template那样的布局设计。因此,它内部包含一个Thumb,允许您仅专注于Thumb进行布局设计。
定义Thumb

接下来,是时候定义将在Track内移动的Thumb了。

  • 扩展并定义Thumb模板
  • 实现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>

这说明了Thumb被直接扩展并实现在Track内部。语法可能难以理解,但教程视频中有详细的视觉展示,因此观看视频是推荐的。

Track不同,Thumb允许通过模板进行控件定义,这意味着Thumb继承自Control,而不是FrameworkElement。因此,ThumbControlTemplate允许灵活的控件设计。

检查执行结果
  • ThumbEllipse)设计
  • Track移动功能

由于Thumb被设计为Ellipse,这个大小为(50x50)的椭圆将在Track区域内移动。但是,如果您将Track的名称从PART_Track更改为其他名称,Thumb的移动将立即丢失。

再次尝试更改名称以理解这种关系。

16. 添加Slider条

接下来,我们将添加滑块条。此步骤涉及添加纯粹的设计相关元素,这些元素不会影响功能。因此,可以跳过此步骤而不影响功能,但考虑到下一步涉及将设计元素与SelectionRange结合,此任务也需要仔细关注。

布局更改

到目前为止,布局只包含Border内的Track元素。但是,添加滑块条需要更改现有布局。此外,由于滑块条和Track需要重叠,因此使用Grid是最佳方法。因此,第一步是将Track包装在Grid中。

  • Layout更改为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中不需要RowDefinitionsColumnDefinitions,因为我们只需要简单的叠加效果。

将Slider条添加到与Track重叠的位置

虽然滑块条应放置在与Track重叠的位置,但逻辑上考虑哪个元素应该在前是至关重要的。TrackThumb控件应该覆盖滑块条区域,因此在Track之前添加和声明滑块条是至关重要的。

  • 添加(Border)滑块条
  • 高度:2.5
  • 背景:#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>

使用像Border这样的布局元素对于视觉上表示Track的长度非常有效。特别是,BorderCornerRadius属性允许圆角,比其他控件具有设计优势。

检查执行结果
  • ThumbEllipse)的移动
  • 滑块条(Border)的设计

此步骤的关键在于使滑块条的设计和位置与Track的移动路径和Thumb的移动和谐地对齐。

17. 调整Slider条和Track之间的间隙

尽管滑块条的设计和放置似乎已妥善安排,但实际上,Track的移动范围受到Thumb在起始端和结束端半径的限制。在检查原始WPF源代码时,您可以找到类似的代码

Canvas.SetLeft(rangeElement, (thumbSize.Width * 0.5) + 
               Math.Max(Maximum - SelectionEnd, 0) * valueToSize);

上述源代码基于“Horizontal”方向。因此,如果方向更改为“Vertical”,它将更改为Height。您能检查一下是否正确吗?

如上文代码所示,可以推断出Track的实际移动范围在内部也受ThumbSize半径的限制,两边都有。因此,我们之前添加的滑块条不是在Slider控件内管理的PART_元素,所以我们必须直接应用此规则。虽然有动态处理的方法,但在此工作中,我们将通过Margin属性精确地将滑块条与Track移动范围之间的边距对齐。

Thumb Ellipse不透明度设置

为了使工作更舒适,我们指定了Ellipse控件的不透明度。

  • Ellipse Fill#55000000
    <Ellipse Width="50" Height="50" Fill="#55000000"/>

在WPF中,通常使用Opacity属性来指定元素的透明度。然而,使用颜色的alpha值对该特定颜色应用透明度可能更有用。这是WPF中的一个实用技巧,所以请好好利用它。

在滑块条上应用等于Thumb半径的Margin

由于当前的EllipseWidth50,我们在两侧都应用了Margin25

  • 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>
结果验证
  • Margin间隙等于Thumb半径已确认

结果是,Track的最大移动范围和滑块条的设计尺寸精确匹配。

此外,探索动态处理此Sync任务的想法是值得的。一个直接的想法是将此滑块条控件指定为PART_,然后在CodeBehind中处理它。有各种方法可以考虑,所以花些时间思考一下。

18. PART_SelectionRange

如前所述,SelectionRange是指定一定范围的元素。

此控件与Track一样,是一个PART_元素,并且完全在Slider控件内管理,因此只需以承诺的名称放置即可。设计应与之前添加的滑块条的高度相同,以保持一致的外观。

添加SelectionRange Border区域
  • NamePART_SelectionRange
  • Height2.5
  • Background#000000
  • Margin25 0 25 0
<Border x:Name="PART_SelectionRange" 
        Background="#000000" 
        Height="2.5"
        Margin="25 0 25 0"/>
指定范围

对于SelectionEnd,通过RelativeSource Binding将范围与Value同步。

  • SelectionStart0
  • SelectionEnd{Binding RelativeSource {RelativeSource Self}, Path=Value}
<Setter Property="SelectionStart" Value="0"/>
<Setter Property="SelectionEnd" 
 Value="{Binding RelativeSource={RelativeSource Self}, Path=Value}"/>

通过将SelectionEnd的值与Value同步,您可以动态表示范围。实际《英雄联盟》客户端应用程序中的Slider控件就是这样实现的。

启用IsSelectionRangeEnabled

考虑到Riot Slider控件的概念,此过程可能不是必需的。但是,由于它可以通过触发器轻松处理,所以我们出于学习目的继续进行。

本部分未在教程视频中涵盖。

  • IsSelectionRangeEnabledTrue
    <Setter Property="IsSelectionRangeEnabled" Value="True"/>

IsSelectionRangeEnabled属性的默认值设置为True。

  • PART_SelectionRange Visibility:(默认) Collapsed
    <Border x:Name="PART_SelectionRange" 
            Background="#000000" 
            Height="2.5"
            Margin="25 0 25 0"
            Visibility="Collapsed"/>

SelectionRange的默认Visibility值设置为Collapsed。

  • TriggerPART_SelectionRange.Visibility=Visible
    <Trigger Property="IsSelectionRangeEnabled" Value="True">
        <Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible"/>
    </Trigger>
 

SelectionRange的默认可见性设置为Collapsed,但当IsSelectionRangeEnabled属性值为True时,通过触发器将Visibility值更改为Visible。虽然也可以反向应用,但在触发器中检查布尔属性的True值是更直接、更常见的编码约定。

源代码和执行结果
  • Setter已应用
  • SelectionRange (默认) Collapsed
  • 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.Trigger>
                        <Trigger Property="IsSelectionRangeEnabled" Value="true">
                               <Setter TargetName="PART_SelectionRange" 
                                Property="Visibility" Value="Visible"/>
                        </Trigger>
                    </ControlTemplate.Trigger>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

现在我们已经为Slider添加了所有功能元素,让我们在进入下一阶段之前,重新检查PART_控件元素的功能,以此结束此步骤。

重新检查PART_控件功能的操作
  • PART_Track
  • PART_SelectionRange

19. 添加Riot风格设计元素

接下来,是时候添加Riot Slider所需的设计元素了。

添加几何图形设计资源
  • 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设计资源
  • LinearGradientBrushThumbColor
  • LinearGradientBrushThumbOver
  • LinearGradientBrushThumbDrag
  • SolidColorBrushSliderColor
  • LinearGradientBrushRangeColor
  • LinearGradientBrushSliderOver
  • LinearGradientBrushSliderDrag
<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或任何包含取色器工具的应用程序。

对于疑似渐变的一部分颜色,尝试在视觉上分割区域,并多次使用取色器工具提取颜色。随着练习,您的辨色能力会提高。

20. 实现Riot风格的Thumb

现在是时候使用准备好的几何图形和设计元素来创建真正的《英雄联盟》风格Thumb控件了。

在我们开始之前,需要处理掉之前在定义Thumb模板时使用的临时Ellipse。因此,我们将删除在Thumb中用Ellipse定义的所有部分。

处理现有Thumb
  • 完全移除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模板
  • 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控件样式相同的.XAML文件中。

Thumb继承自Control,允许通过模板(ControlTemplate)进行设计,从而可以创建具有详细触发器实现的另一个控件。为了创建更详细的控件,Thumb可以使用CustomControl方法进一步优化,这在WPF的默认控件中非常常见。

进一步探索,我们发现像ToolBarOverflowPanel这样的控件,虽然可能听起来不熟悉,但数量众多。这些是更专业的控件,创建在CustomControl的伞下,通常分组在Primitives命名空间下。

因此,此命名空间下的控件通常嵌入在其他(CustomControl)控件中。例如,ToggleButton,它作为CheckBox/RadioButton的父级,但也用于ComboBox等控件的模板中以切换项目。

有趣吧?这些架构概念适用于所有共享XAML的平台,使其在AvaloniaUI、Uno、MAUI等环境中都很有用。

然而,并非Primitives命名空间下所有打包的控件都一定遵循
CustomControl方法,该方法由DefaultStyleKey指示。许多只是包装类。

21. 声明Thumb资源

最后,将Thumb声明为资源,以便在Track中将其引用为StaticResource

添加Thumb资源
  • 如前所述,定义Thumb样式和Thumb资源
    <Thumb x:Key="SliderThumb" Style="{StaticResource ThumbStyle}"/>

这部分在教程视频中也有详细说明,所以如果语法感觉生硬,建议观看以获得清晰度。

现在,Thumb资源可以在Track中使用。

简洁定义Track中的Thumb
  • 将现有的Thumb替换为连接到StaticResource的一行
    <Track Thumb="{StaticResource SliderThumb}"/>

将Thumb用作资源可以大大减少将Thumb应用于Track时的源代码量。它也有助于一目了然地理解整体资源,因此这种资源管理方法对于保持代码质量一致性至关重要。请密切关注掌握这种方法。

22. 完成RiotSlider模板(最终确定)

至此,RiotSlider控件模板的实现已完成。此外,还包含了Jamesnet.Wpf库以使用JamesGrid,但如果愿意,也可以替换为标准的Grid

(CustomControl)RiotSlider
  • 检查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)实现的,因此更容易像资源包一样管理相关资源。

最终结果验证
  • 测试与PART_Track相关的函数
  • 测试与PART_SelectionRange相关的函数
  • 验证设计元素的应用程序

虽然已通过从分析到实现的各个阶段探讨了功能方面,但建议根据PART_控件再次检查功能。

至此,分析基本Slider控件并基于(CustomControl)实现《英雄联盟》风格RiotSlider控件的开发过程和教程视频回顾就完成了。

视频和本文内容可能存在差异,或者源代码可能存在错误。如有任何重大问题,请随时提出。

23. 最后的话

我们深入探讨了创建看似简单的WPF Slider控件的架构方面。关于如此看似微不足道的事情有如此多的讨论,这表明WPF的设计方面有很多值得学习的地方。务必也查看教程视频。Vicky通过视频的解读也很有趣。

WPF是一个较旧的平台,因此多年来已经发展和变化了各种开发方法、框架和开源库。随着时间的推移,主流的评估和解释将继续变化。因此,我们所走的历程都可以作为我们技术的基础。灵活地判断和评估这些可以带来更丰富、更高质量的参考。主流并不总是唯一的答案。

这篇评论,与其说是评论,不如说是我希望能够触及许多人的愿望。

祝大家节日快乐!谢谢。

历史

  • 2024年2月14日:初始版本
© . All rights reserved.