1. 前言 最近在自定義Expander的樣式,順便看了看它的源碼。 Expander控制項是一個ContentControl,它通過IsExpanded屬性或者通過點擊Header中的ToggleButton控制內容展開或隱藏。UWP SDK中沒提供這個控制項,而是在UWP Community Too ...
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)
、 Expanded
和VisualStateManager.GoToState(this, StateContentCollapsed, true);
、 OnCollapsed
、 Collapsed
。
OnExpanded
和OnCollapsed
都是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上。