[UWP]实现Picker控件
1. 前言
在WPF中,很多打开下拉框(Popup或Flyout)选择一个结果值的控件,除了ComboBox等少数例外,这种控件都以-Picker做名称后缀。因为要打开关闭下拉框和计算下拉框的弹出位置, 这类控件实现起来还挺麻烦的。Silverlight Toolkit中贴心地提供了一个Picker控件,可以作为这类控件的基类,省略了大量代码。
2. 现在的问题
由于UWP中有Flyout,-Picker控件的实现其实算是相当轻松的。如ColorPicker的官方文档就介绍了使用Flyout承载ColorPicker的实现代码。但是做起来还是有一些问题:
- 在有“确定/取消”按钮的Flyout中,即使选择了值,如果没有点击“确定”按钮也不更新结果值。
- 在Flyout打开的状态,还是希望它所属的按钮有某种已被按下的状态显示,典型的如ComboBox、Extended WPF Toolkit的ColorPicker、WinForm的DateTimePicker,或其它大部分Windows的控件那样:
上面第一点是硬性要求,所有-Picker类控件都会实现这点(偶尔也见到没做好的)。第二点就比较麻烦了,UWP几乎完全没有理会这点。其实WPF/Silverlight时代即已经开始忽略这点UI需求了,但我还是希望可以注意这些UI的细节,毕竟UWP就经常被诟病UI细节缺失。
3. 我的解决方案
于是我决定实现一个UWP的Picker类。
3.1 定义外观
SilverlightToolkit的Picker相当复杂,UI有三个VisualStateGroup,两个TemplatPart:
[TemplateVisualState(GroupName = "PopupStates", Name = "PopupClosed")] [TemplatePart(Name = "DropDownToggle", Type = typeof (ToggleButton))] [TemplateVisualState(GroupName = "PopupStates", Name = "PopupOpened")] [TemplateVisualState(GroupName = "CommonStates", Name = "Normal")] [TemplateVisualState(GroupName = "CommonStates", Name = "MouseOver")] [TemplateVisualState(GroupName = "CommonStates", Name = "Pressed")] [TemplateVisualState(GroupName = "CommonStates", Name = "Disabled")] [TemplateVisualState(GroupName = "FocusStates", Name = "Focused")] [TemplateVisualState(GroupName = "FocusStates", Name = "Unfocused")] [TemplatePart(Name = "Popup", Type = typeof (Popup))] public abstract class Picker : Control, IUpdateVisualState
我不想做到这么复杂,只有一个名为PopupStatesName的VisualStateGroup(沿用Silverlight Toolkit的命名),一个Flyout,一组“确定/取消”按钮就够了。
[TemplateVisualState(Name = PopupClosedName, GroupName = PopupStatesName)] [TemplateVisualState(Name = PopupOpenedName, GroupName = PopupStatesName)] [TemplatePart(Name = AcceptButtonName, Type = typeof(Button))] [TemplatePart(Name = DismissButtonName, Type = typeof(Button))] [TemplatePart(Name = FlyoutName, Type = typeof(FlyoutBase))] public abstract class Picker : HeaderedContentControl { private const string PopupClosedName = "PopupClosed"; private const string PopupOpenedName = "PopupOpened"; private const string PopupStatesName = "PopupStates"; private const string FlyoutName = "Flyout"; private const string AcceptButtonName = "AcceptButton"; private const string DismissButtonName = "DismissButton";
此为下文中MyDatePicker的运行效果。
3.2 IsOpen属性
Picker中提供一个bool IsDropDownOpen
属性,用于控制下拉框是否打开。无论是AcceptButton或DismissButton的点击事件,或者Flyout的Closed事件都会调用这个属性。
protected virtual void OnIsDropDownOpenChanged(bool oldValue, bool newValue) { if (_flyout == null) return; if (newValue) _flyout.ShowAt(this); else _flyout.Hide(); } protected virtual void OnFlyoutClosed(object e) { IsDropDownOpen = false; } protected virtual void OnAccept(RoutedEventArgs e) { IsDropDownOpen = false; } protected virtual void OnDismiss(RoutedEventArgs e) { IsDropDownOpen = false; } protected override void UpdateVisualState(bool useTransitions) { base.UpdateVisualState(useTransitions); VisualStateManager.GoToState(this, IsDropDownOpen ? PopupOpenedName : PopupClosedName, useTransitions); }
3.3 实际应用:实现一个MyDatePicker
需要实现一个MyDatePicker,可以继承Picker,Default Style如下,不少内容都是参考DatePicker:
<Style TargetType="local:MyDatePicker"> <Setter Property="IsTabStop" Value="False" /> <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:MyDatePicker"> <StackPanel x:Name="LayoutRoot" Margin="{TemplateBinding Padding}"> <local:HeaderedContentControl Header="{TemplateBinding Header}" HeaderTemplate="{TemplateBinding HeaderTemplate}"> <ToggleButton x:Name="DateButton" Content="{TemplateBinding DateTime}" IsEnabled="{TemplateBinding IsEnabled}" IsChecked="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=IsDropDownOpen,Mode=TwoWay}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"> <FlyoutBase.AttachedFlyout> <Flyout Placement="Bottom" x:Name="Flyout"> <Flyout.FlyoutPresenterStyle> <Style TargetType="FlyoutPresenter"> <Setter Property="Padding" Value="0" /> <Setter Property="BorderThickness" Value="0" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="FlyoutPresenter"> <ContentPresenter Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}" ContentTransitions="{TemplateBinding ContentTransitions}" Margin="{TemplateBinding Padding}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" /> </ControlTemplate> </Setter.Value> </Setter> </Style> </Flyout.FlyoutPresenterStyle> <Grid> <Grid.RowDefinitions> <RowDefinition /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <CalendarView x:Name="Calendar" Style="{TemplateBinding CalendarViewStyle}" /> <Grid Grid.Row="1" Height="45" x:Name="AcceptDismissHostGrid"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*" /> <ColumnDefinition Width="*" /> </Grid.ColumnDefinitions> <Button x:Name="AcceptButton" Grid.Column="0" Content="" FontFamily="{ThemeResource SymbolThemeFontFamily}" FontSize="16" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Style="{StaticResource DateTimePickerFlyoutButtonStyle}" Margin="0,2,0,0" /> <Button x:Name="DismissButton" Grid.Column="1" Content="" FontFamily="{ThemeResource SymbolThemeFontFamily}" FontSize="16" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Style="{StaticResource DateTimePickerFlyoutButtonStyle}" Margin="0,2,0,0" /> </Grid> </Grid> </Flyout> </FlyoutBase.AttachedFlyout> </ToggleButton> </local:HeaderedContentControl> </StackPanel> </ControlTemplate> </Setter.Value> </Setter> </Style>
注意这里的ToggleButton使用TwoWay Binding将IsChecked绑定到Picker的IsDropDownOpen属性,通过IsChecked属性与Flyout的Show/Hide关联起来。
另外,Flyout里放了一个CalendarView,用于选择日期。
在MyDatePicker.cs里除了属性,主要的内容是这段代码:
protected override void OnAccept(RoutedEventArgs e) { base.OnAccept(e); if (_calendar != null && _calendar.SelectedDates.Any()) DateTime = _calendar.SelectedDates.First().DateTime; }
重写OnAccept(即点击AcceptButton触发的事件)并为DateTime选择值。
3.4 实际应用:实现一个MyTimePicker
使用TemplatePart的一个重要原则是:即使ControlTemplate中缺少声明的TemplatePart,模板化控件也不会报错,只会缺少部分功能。
根据这个原则实现的MyTimePicker就缺少了AcceptButton和DismissButton,因为使用了TimePickerFlyout,这个控件本身就有AcceptButton和DismissButton按钮。
<Style TargetType="local:MyTimePicker"> <Setter Property="IsTabStop" Value="False" /> <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:MyTimePicker"> <StackPanel x:Name="LayoutRoot" Margin="{TemplateBinding Padding}"> <local:HeaderedContentControl Header="{TemplateBinding Header}" HeaderTemplate="{TemplateBinding HeaderTemplate}"> <ToggleButton x:Name="DateButton" Content="{Binding RelativeSource={RelativeSource Mode=TemplatedParent},Path=Time,Converter={StaticResource TimeSpanToStringConverter}}" IsEnabled="{TemplateBinding IsEnabled}" IsChecked="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=IsDropDownOpen,Mode=TwoWay}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"> <FlyoutBase.AttachedFlyout> <TimePickerFlyout x:Name="Flyout" Placement="Bottom"/> </FlyoutBase.AttachedFlyout> </ToggleButton> </local:HeaderedContentControl> </StackPanel> </ControlTemplate> </Setter.Value> </Setter> </Style>
protected override void OnApplyTemplate() { base.OnApplyTemplate(); _timePickerFlyout = GetTemplateChild(FlyoutName) as TimePickerFlyout; if (_timePickerFlyout != null) _timePickerFlyout.TimePicked += OnTimePicked; UpdateTimePicerFlyoutPickedTime(); UpdateVisualState(false); } protected virtual void OnTimeChanged(TimeSpan oldValue, TimeSpan newValue) { UpdateTimePicerFlyoutPickedTime(); } protected override void OnIsDropDownOpenChanged(bool oldValue, bool newValue { base.OnIsDropDownOpenChanged(oldValue, newValue); if (newValue) UpdateTimePicerFlyoutPickedTime(); } private void UpdateTimePicerFlyoutPickedTime() { if (_timePickerFlyout != null) _timePickerFlyout.Time = Time; } private void OnTimePicked(TimePickerFlyout sender, TimePickedEventArgs args) { Time = args.NewTime; }
4. 结语
细心的话会发现Picker虽然定义了PopupStates这个VisualStateGroup,但从来没用到。其实这是为了将来可能会用到这个这组状态而预留的。值得一提的是Picker不止可以针对弹出Flyout的控件,将ToggleButton和它的Flyout换成Expander也一样适用。
有了Picker类后确实方便了很多。其实Silverlight虽然死了,但开源的Silverlight Toolkit中有不少内容都可用作参考。本来还想给出Silverlight Toolkit中Picker的源码地址作为参考,但最近CodePlex关闭服务了。有兴趣的人仍可直接下载Silverlight Toolkit的源码,毕竟还是挺有趣的。
Silverlight Toolkit - CodePlex Archive
5. 参考
Guidelines for date and time controls