[UWP]理解及扩展Expander

2017-09-13 20:36:11来源:cnblogs.com作者:dino.c人点击

分享

1. 前言

最近在自定义Expander的样式,顺便看了看它的源码。
Expander控件是一个ContentControl,它通过IsExpanded属性或者通过点击Header中的ToggleButton控制内容展开或隐藏。UWP SDK中没提供这个控件,而是在UWP Community Toolkit中 提供 。它是个教科书式的入门级控件,代码简单,虽然仍然不尽如人意,但很适合用于学习如何自定义模版化控件。

2.详解

[ContentProperty(Name = "Content")][TemplatePart(Name = "PART_RootGrid", Type = typeof(Grid))][TemplatePart(Name = "PART_ExpanderToggleButton", Type = typeof(ToggleButton))][TemplatePart(Name = "PART_LayoutTransformer", Type = typeof(LayoutTransformControl))][TemplateVisualState(Name = "Expanded", GroupName = "ExpandedStates")][TemplateVisualState(Name = "Collapsed", GroupName = "ExpandedStates")][TemplateVisualState(Name = "LeftDirection", GroupName = "ExpandDirectionStates")][TemplateVisualState(Name = "DownDirection", GroupName = "ExpandDirectionStates")][TemplateVisualState(Name = "RightDirection", GroupName = "ExpandDirectionStates")][TemplateVisualState(Name = "UpDirection", GroupName = "ExpandDirectionStates")]public class Expander : ContentControl{    public Expander();    public string Header { get; set; }    public DataTemplate HeaderTemplate { get; set; }    public bool IsExpanded { get; set; }    public ExpandDirection ExpandDirection { get; set; }    public event EventHandler Expanded;    public event EventHandler Collapsed;    public void OnExpandDirectionChanged();    protected override void OnApplyTemplate();    protected virtual void OnCollapsed(EventArgs args);    protected virtual void OnExpanded(EventArgs args);}

以上是Expander的代码定义,可以看出这个控件十分简单。本文首先对代码和XAML做个详细了解。这部分完全是面向初学者的,希望初学者通过Expander的源码学会一个基本的模板化控件应该如何构造。

2.1 Attribute

Expander定义了三种Attribute:ContentProperty、TemplatePart和TemplateVisualState。
ContentProperty表明了主要属性为Content,并且在XAML中可以将Content属性用作直接内容,即将这种代码:

<controls:Expander>    <controls:Expander.Content>        <TextBlock Text="Text" />    </controls:Expander.Content></controls:Expander>

简化成如下形式:

<controls:Expander>    <TextBlock Text="Text" /></controls:Expander>

因为Expander本来就继承自ContentControl,我很怀疑定义这个ContentProperty的必要性。(如果各位清楚这里这么做的原因请告知,谢谢。)

TemplatePart表明ControlTemplate中应该包含名为PART_ExpanderToggleButton的ToggleButton、名为PART_RootGrid的Grid及名为PART_LayoutTransformer的LayoutTransformControl。

TemplateVisualState表明ControlTempalte中应该包含名为ExpandedStates的VisualStateGroup,其中包含名为Expanded和Collapsed的两种VisualState。另外还有名为ExpandDirectionStates的VisualStateGroup,其中包含RightDirection、LeftDirection、UpDirection和DownDirection。

即使ControlTemplate中没按TemplatePart和TemplateVisualState的要求定义,Expander也不会报错,只是会缺失部分功能。

2.2 Header与HeaderTemplate

PART_ExpanderToggleButton的Content和ContentTemplate通过TemplateBinding绑定到Expander的Header和HeaderTemplate,通过HeaderTemplate,Expander的Header外观可以有一定的灵活性。

2.3 IsExpanded

Expander通过IsExpanded属性控制内容是否展开。注意这是个依赖属性,即这个属性也可以通过Binding控制。在改变IsExpanded值的同时会依次调用VisualStateManager.GoToState(this, StateContentExpanded, true);OnExpanded(EventArgs args)ExpandedVisualStateManager.GoToState(this, StateContentCollapsed, true);OnCollapsedCollapsed
OnExpandedOnCollapsed都是protected virtual 函数,可以在派生类中修改行为。

许多人实现Expander时不使用IsExpanded属性,而是通过public void Expand()public void Collapse()直接控制内容展开和折叠,这种做法稍微缺乏灵活性。如PART_ExpanderToggleButton通过TwoWay Binding与IsExpanded属性关联,如果只提供public void Expand()public void Collapse()则做不到这个功能。

<ToggleButton x:Name="PART_ExpanderToggleButton"               IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />

另一个常见的做法是通过代码直接控制内容是否显示,例如这样:PART_MainContent.Visibility = Visibility.Collapsed;。这样的坏处是不能在这个过程自定义动画效果或进行其它操作。Expander通过VisualStateManager实现这个功能,做到了UI和代码分离。

2.4 OnApplyTemplate

模板化控件在加载ControlTemplate后会调用OnApplyTemplate(),Expander的OnApplyTemplate()实现了通常应有的实现,即订阅事件、改变VisualState。

protected override void OnApplyTemplate(){    base.OnApplyTemplate();    if (IsExpanded)    {        VisualStateManager.GoToState(this, StateContentExpanded, false);    }    else    {        VisualStateManager.GoToState(this, StateContentCollapsed, false);    }    var button = (ToggleButton)GetTemplateChild(ExpanderToggleButtonPart);    if (button != null)    {        button.KeyDown -= ExpanderToggleButtonPart_KeyDown;        button.KeyDown += ExpanderToggleButtonPart_KeyDown;    }    OnExpandDirectionChanged();}

控件在加载ControlTemplate时就需要确定它的状态,一般这时候都不会使用过渡动画。所以这里VisualStateManager.GoToState(this, StateContentExpanded, false)的参数useTransitions使用了false。
由于Template可能多次加载(实际很少发生),或者不能正确获取TemplatePart,所以使用TemplatePart前应该先判断是否为空;如果要订阅事件,应该先取消订阅。

2.5 Style

<Style TargetType="controls:Expander">    <Setter Property="Header" Value="Header" />    <Setter Property="IsTabStop" Value="False" />    <Setter Property="Template">        <Setter.Value>            <ControlTemplate TargetType="controls:Expander">                <Grid>                    <VisualStateManager.VisualStateGroups>                        <VisualStateGroup x:Name="ExpandedStates">                            <VisualState x:Name="Expanded">                                <VisualState.Setters>                                    <Setter Target="PART_MainContent.Visibility" Value="Visible" />                                </VisualState.Setters>                            </VisualState>                            <VisualState x:Name="Collapsed">                                <VisualState.Setters>                                    <Setter Target="PART_RootGrid.Background" Value="Transparent" />                                </VisualState.Setters>                            </VisualState>                        </VisualStateGroup>                        <VisualStateGroup x:Name="ExpandDirectionStates">                            ....                        </VisualStateGroup>                    </VisualStateManager.VisualStateGroups>                    <Grid x:Name="PART_RootGrid" Background="{TemplateBinding Background}">                        <Grid.RowDefinitions>                            <RowDefinition Height="Auto" />                            <RowDefinition Height="Auto" />                        </Grid.RowDefinitions>                        <Grid.ColumnDefinitions>                            <ColumnDefinition x:Name="ColumnOne" Width="Auto" />                            <ColumnDefinition x:Name="ColumnTwo" Width="*" />                        </Grid.ColumnDefinitions>                        <controls:LayoutTransformControl Grid.Row="0" Grid.RowSpan="1" Grid.Column="0" Grid.ColumnSpan="2"                                                         x:Name="PART_LayoutTransformer"                                                         RenderTransformOrigin="0.5,0.5">                            <controls:LayoutTransformControl.Transform>                                <RotateTransform x:Name="RotateLayoutTransform" Angle="0" />                            </controls:LayoutTransformControl.Transform>                            <ToggleButton x:Name="PART_ExpanderToggleButton"                                           Height="40"                                          TabIndex="0"                                          AutomationProperties.Name="Expand"                                          Style="{StaticResource HeaderToggleButtonStyle}"                                           VerticalAlignment="Bottom" HorizontalAlignment="Stretch"                                           Foreground="{TemplateBinding Foreground}"                                          Background="{TemplateBinding Background}"                                          ContentTemplate="{TemplateBinding HeaderTemplate}" Content="{TemplateBinding Header}"                                          IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />                        </controls:LayoutTransformControl>                        <ContentPresenter Grid.Row="1" Grid.RowSpan="1" Grid.Column="0" Grid.ColumnSpan="2"                                          x:Name="PART_MainContent"                                          Background="{TemplateBinding Background}"                                          HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"                                          HorizontalContentAlignment="Stretch"                                          VerticalAlignment="{TemplateBinding VerticalContentAlignment}"                                          Visibility="Collapsed" />                    </Grid>                </Grid>            </ControlTemplate>        </Setter.Value>    </Setter></Style>

如果忽略ExpandDirectionStates,Expander的Style就如以上所示十分简短(不过HeaderToggleButtonStyle有整整300行)。注意 Setter Property="IsTabStop" Value="False" 这句,对内容控件或复合控件,约定俗成都需要将IsTabStop设置成False,这是为了防止控件本身获得焦点。对Expander来说,在前一个控件上按“Tab”键,应该首先让PART_ExpanderToggleButton获得焦点。如果IsTabStop="true",Expander会获得焦点,需要再按一次“Tab”键才能让PART_ExpanderToggleButton获得焦点。

2.6 partial class

即使代码量不大,Expander还是将代码分别存放在几个partial class中,这样做的好处是让承载主要业务的文件(Expander.cs)结构更加清晰。尤其是依赖属性,一个完整的依赖属性定义可以有20行(属性标识符、属性包装器、PropertyChangedCallback等),而且其中一部分是静态的,另外一部分不是,在类中将一个依赖属性的所有部分放在一起,还是按静态、非静态的顺序存放,这也可能引起争论。

2.7 其它

虽然Expander是一个教科书式的控件,但还是有几个可以改进的地方。

最让人困扰的一点是Header居然是个String。WPF中的Expander的Header是个Object,可以方便地塞进各种东西,例如一个CheckBox或一张图片。虽然通过更改ControlTemplate或HeaderTemplate也不是不可以达到这效果,但毕竟麻烦了一些。不久前MenuItem就把Header从String类型改为Object了(Menu: changed MenuItem Header to type object),说不定以后Expander也有可能这样修改( Change Expander.Header from string to object )。

另外,在WPF中Expander派生自HeaderedContentControl,这就少写了Header、HeaderTemplate、OnHeaderChanged等一大堆代码。而Community Toolkit中每个有Header属性的控件都各自重复了这些代码。或许将来会有HeaderedContentControl这个控件吧。

PART_ExpanderToggleButton鼠标按下时Header和Content分裂的效果还挺奇怪的,这点在上一篇文章有提过( 浅谈按钮设计)。

最后,这年头连个折叠/展开动画都没有,而且还是微软出品,真是可惜(Improve Expander control (animation, color))。还好XAML扩展性确实优秀,可以自己添加这些动画。

3. 扩展

我简单地用Behavior为Expander添加了折叠/展开动画,代码如下:

public class PercentageToHeightBehavior : Behavior<StackPanel>{    /// <summary>    /// 获取或设置ContentElement的值    /// </summary>      public FrameworkElement ContentElement    {        get { return (FrameworkElement)GetValue(ContentElementProperty); }        set { SetValue(ContentElementProperty, value); }    }          protected virtual void OnContentElementChanged(FrameworkElement oldValue, FrameworkElement newValue)    {        if (oldValue != null)            newValue.SizeChanged -= OnContentElementSizeChanged;        if (newValue != null)            newValue.SizeChanged += OnContentElementSizeChanged;    }    private void OnContentElementSizeChanged(object sender, SizeChangedEventArgs e)    {        UpdateTargetHeight();    }    /// <summary>    /// 获取或设置Percentage的值    /// </summary>      public double Percentage    {        get { return (double)GetValue(PercentageProperty); }        set { SetValue(PercentageProperty, value); }    }          protected virtual void OnPercentageChanged(double oldValue, double newValue)    {        UpdateTargetHeight();    }    public event PropertyChangedEventHandler PropertyChanged;    private void UpdateTargetHeight()    {        double height = 0;        if (ContentElement == null || ContentElement.ActualHeight == 0 || double.IsNaN(Percentage))            height = double.NaN;        else            height = ContentElement.ActualHeight * Percentage;        if (AssociatedObject != null)            AssociatedObject.Height = height;    }}
<Grid>    <VisualStateManager.VisualStateGroups>        <VisualStateGroup x:Name="ExpandedStates">            <VisualStateGroup.Transitions>                <VisualTransition  To="Collapsed">                    <Storyboard>                        <DoubleAnimation Duration="0:0:0.5"                                         To="0"                                         Storyboard.TargetProperty="(UIElement.Opacity)"                                         Storyboard.TargetName="PART_MainContent">                            <DoubleAnimation.EasingFunction>                                <CubicEase EasingMode="EaseOut" />                            </DoubleAnimation.EasingFunction>                        </DoubleAnimation>                        <DoubleAnimation BeginTime="0:0:0"                                         Duration="0:0:0.5"                                         To="0"                                         Storyboard.TargetProperty="(local:PercentageToHeightBehavior.Percentage)"                                         Storyboard.TargetName="PercentageToHeightBehavior"                                         EnableDependentAnimation="True">                            <DoubleAnimation.EasingFunction>                                <QuinticEase EasingMode="EaseIn" />                            </DoubleAnimation.EasingFunction>                        </DoubleAnimation>                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)"                                                       Storyboard.TargetName="PART_MainContent">                            <DiscreteObjectKeyFrame KeyTime="0:0:0.5">                                <DiscreteObjectKeyFrame.Value>                                    <Visibility>Collapsed</Visibility>                                </DiscreteObjectKeyFrame.Value>                            </DiscreteObjectKeyFrame>                        </ObjectAnimationUsingKeyFrames>                    </Storyboard>                </VisualTransition>                <VisualTransition GeneratedDuration="0"                                  To="Expanded">                    <Storyboard>                        <DoubleAnimation BeginTime="0:0:0.0"                                         Duration="0:0:0.5"                                         To="1"                                         Storyboard.TargetProperty="(UIElement.Opacity)"                                         Storyboard.TargetName="PART_MainContent">                            <DoubleAnimation.EasingFunction>                                <CubicEase EasingMode="EaseIn" />                            </DoubleAnimation.EasingFunction>                        </DoubleAnimation>                        <DoubleAnimation BeginTime="0:0:0"                                         Duration="0:0:0.5"                                         To="1"                                         Storyboard.TargetProperty="(local:PercentageToHeightBehavior.Percentage)"                                         Storyboard.TargetName="PercentageToHeightBehavior"                                         EnableDependentAnimation="True">                            <DoubleAnimation.EasingFunction>                                <QuinticEase EasingMode="EaseOut" />                            </DoubleAnimation.EasingFunction>                        </DoubleAnimation>                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)"                                                       Storyboard.TargetName="PART_MainContent">                            <DiscreteObjectKeyFrame KeyTime="0:0:0">                                <DiscreteObjectKeyFrame.Value>                                    <Visibility>Visible</Visibility>                                </DiscreteObjectKeyFrame.Value>                            </DiscreteObjectKeyFrame>                        </ObjectAnimationUsingKeyFrames>                    </Storyboard>                </VisualTransition>            </VisualStateGroup.Transitions>            <VisualState x:Name="Expanded">                <VisualState.Setters>                    <Setter Target="PART_MainContent.(UIElement.Opacity)"                            Value="1" />                    <Setter Target="PercentageToHeightBehavior.(local:PercentageToHeightBehavior.Percentage)"                            Value="1" />                    <Setter Target="PART_MainContent.Visibility"                            Value="Visible" />                </VisualState.Setters>            </VisualState>            <VisualState x:Name="Collapsed">            </VisualState>        </VisualStateGroup>    </VisualStateManager.VisualStateGroups>    <Grid>        <Grid.RowDefinitions>            <RowDefinition Height="Auto" />            <RowDefinition Height="Auto" />        </Grid.RowDefinitions>        <ToggleButton x:Name="PART_ExpanderToggleButton"                      Height="40"                      TabIndex="0"                      AutomationProperties.Name="Expand"                      Style="{StaticResource HeaderToggleButtonStyle}"                      VerticalAlignment="Top"                      HorizontalAlignment="Stretch"                      Foreground="{TemplateBinding Foreground}"                      Background="{TemplateBinding Background}"                      ContentTemplate="{TemplateBinding HeaderTemplate}"                      Content="{TemplateBinding Header}"                      IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" />        <StackPanel x:Name="stackPanel"                    Grid.Row="1">            <Interactivity:Interaction.Behaviors>                <local:PercentageToHeightBehavior x:Name="PercentageToHeightBehavior"                                                  ContentElement="{Binding ElementName=PART_MainContent}"                                                  Percentage="0" />            </Interactivity:Interaction.Behaviors>            <ContentPresenter x:Name="PART_MainContent"                              Background="{TemplateBinding Background}"                              HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"                              HorizontalContentAlignment="Stretch"                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}"                              Visibility="Collapsed"                              Opacity="0" />        </StackPanel>    </Grid></Grid>

原理是把ContentPresenter放进一个StackPanel里,通过DoubleAnimation改变这个StackPanel的高度。之所以不直接改变ContentPresenter的高度是不想改变它的内容高度。另外我也改变了PART_ExpanderToggleButton的动画效果,我有点讨厌鼠标按下时文字会变模糊这点。运行效果如下:

4. 结语

写这篇文章拖了很多时间,正好2.0版本也发布了( Releases · Microsoft_UWPCommunityToolkit ),所以截图及源码有一些是不同版本的,但不影响主要内容。
如前言所说,这真的是个很好的入门级控件,很适合用于学习模板化控件。

5. 参考

Expander Control
Microsoft.Toolkit.Uwp.UI.Controls.Expander

6. 源码

GitHub - ExpanderDemo
因为是在v1.5.0上写的,可能需要修改才能使用到v2.0.0上。

最新文章

123

最新摄影

微信扫一扫

第七城市微信公众平台