UWP中的自定义控件

自定义控件是我们在UWP面应用开发中绕不开的一环,因为原生控件不见得能满足我们的全部需求。而自定义控件中的“自定义”,主要包含以下内容:

  1. 修改原生控件的样式,使之符合我们的UI设计
  2. 修改原生控件的功能,在原生控件的基础上定制我们需要的功能。
  3. 组合多个控件,将之作为一个整体控件使用
  4. 针对某个功能创建一个新的控件

这也是本章的提纲,我们将对这四种情况进行解释和说明。

控件样式修改

对于控件样式的修改主要由两种,一种是修改控件的属性,比如宽高,比如背景色前景色。这些虽算作样式修改,但我想我不必在此赘述。

我们来看第二种,模板修改

当涉及到模板修改的时候,通常意味着我们想修改的样式并没有通过属性来提供,而且有个隐藏条件,即该控件是可以通过注入模板的方式来修改样式的。这个隐藏条件我们一会儿来说,先看看我们如何进行模板修改。

我们又需要来修改我们的Button了。简而言之,我们需要让按钮在不同状态下的颜色变化从瞬态变化改变线性变化。

控件并没有暴露属性来给我们进行修改,此时我们需要修改控件的控件模板,而控件模板往往对应的是控件的Template属性,此时我们最常用的方法是:

获取控件的默认样式→在App.xaml或其他资源字典中放置样式→在已有的基础上进行模板修改。

获取控件的默认样式,我们可以在generic.xaml中找,也可以在Visual Studio提供的Document Outline工具中右键对应控件,通过Edit Template/Edit Copy来创建副本。

<Style TargetType="Button">
    <!-- other setter -->
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="Button">
                <ContentPresenter x:Name="ContentPresenter"
                 ...>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CommonStates">
                            <VisualState x:Name="Normal">
                                <Storyboard>
                                    <PointerUpThemeAnimation Storyboard.TargetName="ContentPresenter" />
                                </Storyboard>
                            </VisualState>

                            <VisualState x:Name="PointerOver">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundPointerOver}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushPointerOver}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundPointerOver}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <PointerUpThemeAnimation Storyboard.TargetName="ContentPresenter" />
                                </Storyboard>
                            </VisualState>

                            <VisualState x:Name="Pressed">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundPressed}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushPressed}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundPressed}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <PointerDownThemeAnimation Storyboard.TargetName="ContentPresenter" />
                                </Storyboard>
                            </VisualState>

                            <VisualState x:Name="Disabled">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundDisabled}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushDisabled}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundDisabled}" />
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                </ContentPresenter>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

在新的副本样式中,我们直接定位到Template,对于大多数类似的模板控件来说,控件模板的主要组成部分有两个,一个是各种内部定义的控件,另一个就是状态管理器。

根据我们的需求,我们最主要处理的就是状态管理器。

在Button内部定义了四种状态,分别是Normal, PointerOver, PressedDisabled。在不同的状态下,按钮会显示不同的样式。

以PointerOver举例,其内部有一个Storyboard,这是在XAML中写动画常用的工具,在Storyboard中使用ObjectAnimationUsingKeyFrames,这种关键帧类型并不是我们通常想象的那种带有动态效果的动画,你可以把它理解成一次赋值。简单的赋值不会带来我们预期的动画效果,我们需要把这种关键帧动画换掉。

我们这次不讲动画,所以直接上代码:

<VisualState x:Name="PointerOver">
    <Storyboard>
        <ColorAnimation
            Storyboard.TargetName="ContentPresenter"
            Storyboard.TargetProperty="(UIElement.Background).(SolidColorBrush.Color)"
            To="{ThemeResource SystemBaseLowColor}"
            Duration="0:0:1" />
        <ColorAnimation
            Storyboard.TargetName="ContentPresenter"
            Storyboard.TargetProperty="(UIElement.BorderBrush).(SolidColorBrush.Color)"
            To="{ThemeResource SystemBaseMediumHighColor}"
            Duration="0:0:1" />
        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
            <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundPointerOver}" />
        </ObjectAnimationUsingKeyFrames>
        <PointerUpThemeAnimation Storyboard.TargetName="ContentPresenter" />
    </Storyboard>
</VisualState>

当替换为ColorAnimation后,我们预期的目标就达到了。

像这样一整套流程,就是我们提到的第一种类型的自定义控件:修改原生控件的样式

现在我们再回过头看看之前提到的所谓“隐藏条件”。

当我们尝试创建自定义控件的时候,UWP提供了两种模板,一种叫"UserControl",一种叫"TemplateControl"。

其中TemplateControl才适用上述的模板修改。

UserControl会像创建Page一样创建一个包含了Xaml和Code-behind的组件,UserControl尽管有Template属性,但它不支持对Template属性进行修改。它的XAML是不对外展示的,也是不接受外部注入的。所以当我们使用基类是UserControl的自定义控件时,只能修改其属性,而不能直接赋予它新的样式。

TemplateControl则不同,它的UI代码和类型定义是分开的。你可以为控件创建一个默认的ControlTemplate,但同时,外部也可以注入一个新的ControlTemplate来替换默认样式。所以原生控件基本都是TemplateControl。

TemplateControl并不是一种控件类型,而是指Visual Studio的模板名,通过该模板创建的控件的基类是Control。

派生控件

通过前面的方法,我们可以自定义原生控件的UI,但原生控件有时候并不能满足我们关于功能的需求。

举例来说,我需要一个带图标的按钮(又是按钮)。默认的Button是不带图标的,如果我们想做类似的效果,需要将Icon和Text写在Button.Content之中。

<Button>
    <StackPanel Orientation="Horizontal" Spacing="8">
        <SymbolIcon VerticalAlignment="Center" Symbol="Save" />
        <TextBlock VerticalAlignment="Center" Text="Save" />
    </StackPanel>
</Button>

当我们需要在多处使用类似的按钮时,它很难复用,一旦后期需要修改,那会变成灾难。所以我们迫切需要有一种Button,这个Button可以提供一个Icon属性和一个Content属性以便便捷地设置其图标和文本内容,同时自带一个默认的样式。

但如果重写一个Button就太费事了,我们可以把Button作为基类,派生一个新的IconButton类作为我们的自定义控件。

由于是从Button上派生,那么Button有的我们都有,我们只需要追加一个Icon属性即可。

public sealed class IconButton : Button
{
    public IconButton()
    {
        this.DefaultStyleKey = nameof(IconButton);
    }

    public Symbol Icon
    {
        get { return (Symbol)GetValue(IconProperty); }
        set { SetValue(IconProperty, value); }
    }

    public static readonly DependencyProperty IconProperty =
        DependencyProperty.Register("Icon", typeof(Symbol), typeof(IconButton), new PropertyMetadata(default(Symbol)));
}

有了Icon属性,那么我们就需要让这个Icon显示在控件上,这样我们就需要为我们的新控件提供一个默认的UI,也即默认的控件模板。

由于是派生自Button,所以我们可以直接复制一份Button的默认样式,一切如修改原生控件样式那般,拿到Button默认样式后,我们修改其ControlTemplate:

<ControlTemplate TargetType="local:IconButton">
    <Grid
        x:Name="RootContainer"
        Padding="{TemplateBinding Padding}"
        Background="{TemplateBinding Background}"
        BackgroundSizing="{TemplateBinding BackgroundSizing}"
        BorderBrush="{TemplateBinding BorderBrush}"
        BorderThickness="{TemplateBinding BorderThickness}"
        ColumnSpacing="8"
        CornerRadius="{TemplateBinding CornerRadius}">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <SymbolIcon VerticalAlignment="Center" Symbol="{TemplateBinding Icon}" />
        <ContentPresenter
            x:Name="ContentPresenter"
            Grid.Column="1"
            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
            AutomationProperties.AccessibilityView="Raw"
            Content="{TemplateBinding Content}"
            ContentTemplate="{TemplateBinding ContentTemplate}"
            ContentTransitions="{TemplateBinding ContentTransitions}"
            CornerRadius="{TemplateBinding CornerRadius}" />

        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="CommonStates">
								<!-- Visual states -->
						</VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
    </Grid>

</ControlTemplate>

在修改时我们要注意:

  1. 尽量不要删除默认样式自带的控件(比如Button的ContentPresenter),这些控件可能是控件内部用于展示信息或者获取输入的必须控件。又因为在Code中是通过Name来查询这些控件的,所以也尽量不要修改自带控件的Name。
  2. VisualStateManager一般情况下应放在根元素中,示例中的VisualStateManager就放在Grid中,而不是ContentPresenter。放错位置的VisualStateManager不会生效。

这就是通过派生控件的方式有限度的扩展原生控件的功能。在改动不大时,我们会使用这种方式创建自定义控件。

组合控件

之前我们提到了Visual Studio创建自定义控件的两种模板,其中的UserControl最主要的功能就是用来创建一个UI相对固定的组件,借以复用。

这种组合往往是对现有控件的组合。举个例子,当一张图片和一个文本输入框组合在一起,我们可以想到它是验证码组件。这就是UserControl的主要用途,即组合。

我们都用过DataTemplate,如果你的DataTemplate比较复杂,同时又需要在多个页面复用。那么不妨将其作为一个独立的组件,而这个组件的容器,就是UserControl。

对于需要多人合作的项目而言,将功能涉及的控件组件化,这是一件应该做的事情。

其实关于组合控件来创建自定义控件,这种行为本身并没有什么值得说道的地方。这里需要提一点的是UserControl和TemplateControl的区别。

UserControl与TemplateControl

这两种控件的使用方式因人而异,我是持一个开放的态度,并不是说UserControl或TemplateControl就一定要只处理某种场景下的业务逻辑,只要能满足业务需求,选取哪个都可以。

这里我只说一下自己的一个看法,它们各有优势,我们来做个大概的介绍:

UserControl与TemplateControl的一个最明显的区别就在于XAML和Code-behind的组织形式。UserControl是将两者紧密结合的,这意味着你可以在UserControl.xaml.cs中便捷地获取UI内容,正常使用x:Bind。但不便之处在于,该UserControl的功能在创建的时候就已经确定了,外部在调用的时候难以修改其UI,或为其扩展功能。

TemplateControl可以有效地解决这个问题,它的特点就是将UI代码与逻辑代码分开。对于TemplateControl来说,UI代码就是一件衣服,逻辑代码才是本体,本体不变,衣服可以随便换。这样一来,外部可以很方便地修改控件的样式,自定义控件UI,也可以相对轻松地派生新的控件。但是这同样有问题,UserControl的优势就是TemplateControl的劣势。首先,由于这UI与逻辑这两者分开,你不能直接获取UI控件,其次,x:Bind需要Code-Behind支持,对于TemplateControl来说则无法使用x:Bind,当然,它有自己的绑定方式,我们一会儿再说。另外一个比较大的问题,就在于当UI可以随意变化的时候,谁也不知道别人会把你的控件魔改成什么样,你如何确保使用你控件的人没有删除你在逻辑代码中引用的控件呢?

这里简单地介绍了两种自定义控件的模板类型,两者各有优势,并没有哪种模板更好的说法。最终你选择哪种控件模板来构建你的自定义控件,还是看业务需求,具体问题具体分析。

创建新控件

经过前面的铺垫,现在我们到了自定义控件的一个终极阶段,也就是创建一个全新的控件,就像WinUI或Windows Community Toolkit做的那样。

到这一步,我们通常会选择TemplateControl,因为我们可能需要在后期对控件进行调整抑或是分享给其它项目。这一节其实是更深入地介绍一下TemplateControl的创建流程和一些使用方法。

前面说过,TemplateControl的UI和逻辑是分开的。UI默认会放在Themes/Generic.xaml文件中,它是一个Style,但核心是里面的ControlTemplate。

Themes/Generic.xaml,这个文件路径是一种命名约定。在这个文件路径下的样式会被默认在应用启动时加载,而不需要额外在App.xaml中引入。

在ControlTemplate里面写控件,做各种控件的定义,这个我们不需要多言,我们更关心的是数据展示及数据更新的问题。

前面我们提到过,对TemplateControl而言,UI就像衣服,当别人在用这个控件的时候,对这个衣服不满意可以随便换。但这带来一个新的问题,如果我们的功能依赖于某个特定的部件,这个部件对我们来说是不可或缺的。比如一个用户头像的控件,我需要有一个Image控件来显示图片,一个TextBlock来显示用户名,这必不可少。但如果控件的使用者觉得图片太占地方,把图片控件删掉了,我们如何保证控件还能正常工作,至少不报错。

主要通过两种方式:

  1. 使用TemplateBinding,以数据驱动
  2. 对TemplatePart做null判断

先说说TemplateBinding,我们知道绑定可以帮助我们连接数据与控件,从而避免我们在逻辑代码中获取控件并手动赋值(这是TemplatePart的方式,但这样会带来引用问题,即如果用户删除了某个引用的控件,程序运行时可能会报错)。TemplateBinding是在TemplateControl中常用的绑定方式,它是OneWay Binding的一种优化形式。我们知道,在TemplateControl中用不了x:Bind,此时涉及到数据绑定,一般是使用TemplateBinding,但这可能不够。

因为TemplateBinding是OneWay的,所以如果我们需要双向数据流绑定时,TemplateBinding就帮不到我们了。此时为了不退回到在逻辑代码中引用控件并监听事件的阶段,我们需要使用Binding作为必要的绑定方式。

举一个情景,我有一个TextBlock用于显示文本,一个TextBox用于输入文本,内部有一个Text依赖属性来存储文本数据。

对于文本显示,TemplateBinding足以胜任。但是对于如何获取TextBox中用户输入的文本,TemplateBinding就捉襟见肘了。

此时我们可以使用TwoWay Binding:

<TextBlock Text="{TemplateBinding Text}" />
<TextBox Text="{Binding Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, RelativeSource={RelativeSource Mode=TemplatedParent}}" />

在ControlTemplate中使用Binding需要额外注意的一点就是需要设置数据源,且格式相对固定,因为我们绑定的是我们自定义控件的依赖属性,所以将RelativeSource设置为TemplateParent即可。

如果你之前有WPF的开发经验,那么UWP在这一块是没做什么改动的,你可以较为顺畅地沿用之前的开发经验。不过需要说明的是,UWP对控件名不作要求,我记得WPF时是推荐给控件加一个命名前缀“PART”。

一般来说,逻辑代码越少与UI发生干涉,在这里出错的机率就越小,所以在设计自定义控件时,可以优先考虑使用数据绑定。

再来说说对TemplatePart做null判断。

有些时候,我们必须要依赖控件的某些功能。比如我有一个StackPanel,我需要它能响应鼠标事件改变背景色。UI部分我可以定义一些VisualState,但最终响应鼠标事件以及切换VisualState我需要在逻辑代码中完成。

此时我需要获取到这个StackPanel,并附加一系列鼠标事件。

在逻辑代码中获取Template中定义的控件,可以重写OnApplyTemplate方法,并在其中使用GetTemplateChild来获取控件:

protected override void OnApplyTemplate()
{
    base.OnApplyTemplate();
    var _myPanel = GetTemplateChild("RootPanel") as StackPanel;
    _myPanel.PointerEntered += OnPanelPointerEntered;
    _myPanel.PointerExited += OnPanelPointerExited;
    _myPanel.PointerMoved += OnPanelPointerMoved;
}

这里就需要注意两点:

  1. 如果不是要用到特定于控件的属性,那么做类型转换时最好选择该控件的基类。
  2. ApplyTemplate事件可能不止触发一次,如果你将控件保存为本地变量,记得先解绑一下事件。

这里需要额外说明的是,WPF有一个相关的规范或者说推荐做法,即在类名前写TemplatePartAttribute或TemplateVisualStateAttribute,该特性用于声明这个控件有哪些必备组件。该特性在UWP中同样存在,但已经较少使用了,至少在原生控件和WinUI中并没有看到相关的声明。

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/b0673b52-b4f8-4fcc-af12-1ab5721dcfa4/Untitled.png
WPF ProgressBar
https://s3-us-west-2.amazonaws.com/secure.notion-static.com/1b742085-29e7-42ca-a118-284e8e6d5254/Untitled.png
UWP ProgressBar

这个声明并不具备实际的功能,它不能在用户删除某些控件时给出明确提醒,甚至也不能报错,只是在外部用户看到这个控件类的定义时知道:哦!这个控件不能随便删。

所以最终落回到代码里,我们还是要在涉及到UI控件时先加一层判断,即判断其是否为null,不为null再进行操作。

除此之外还有一种方法,可以避免外部不受控制地修改控件,这种方式就是内插控件

简单来说,就是在逻辑代码中新建控件,插入到现有可视化树中。由于这些控件写在逻辑代码里,外部的XAML不可见,就避免了被修改的问题。但同时,这也意味着牺牲了一定的可扩展性,外部用户也无法完全控制控件的行为。


以上就是在UWP中创建自定义控件的内容了,以后如果有机会,可以写一些关于如何在多项目之间分享控件及样式的内容。

云之幻

云之幻

热衷于敲代码,喜欢设计和产品
China