WPF自定义控件第二 - 转盘按钮控件
继之前那个控件,又做了一个原理差不多的控件。这个控件主要模仿百度贴吧WP版帖子浏览界面左下角那个弹出的按钮盘。希望对大家有帮助。
这个控件和之前的也差不多,为了不让大家白看,文章最后发干货。
由于这个控件和之前一篇文章介绍控件基本差不多,所以一些基本的实现点不再赘述,文本将主要介绍与这个控件功能密切相关的部分。开始正题。
剧透一下,博主后来又在WinRT(真不知道该叫什么好了,现在该叫它UWP吗?)中把这个控件实现了一遍,说起来WinRT与WPF还是有很大不同的,这个控件的实现方式也有很多不同之处。后续的文章将会有介绍。
按惯例先上最终效果图:
弹出的子菜单可以点击,用于自定义需要点击子菜单实现的功能:
首先还是先来展示一下控件模板的基本结构:
基本上分为四部分:定义状态,定义中间的大按钮,圆形透明背景,以及显示一圈小按钮的Panel。
大按钮和圆形透明背景很简单:
<Border Panel.ZIndex="999" x:Name="PART_CenterBtn" VerticalAlignment="Center" HorizontalAlignment="Center" Width="50" Height="50" CornerRadius="25" BorderThickness="0" BorderBrush="Blue" Background="CadetBlue"> <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="读书"></TextBlock> </Border> <Ellipse Width="{TemplateBinding Width}" Height="{TemplateBinding Height}" Panel.ZIndex="-1" Fill="#66559977"></Ellipse>
注意:当前圆形背景不能按设置的角度变成扇形,需要这个功能的童鞋可以自行做一个可以绑定到角度的扇形控件。
最值得关注就是定义的几个状态,子菜单正是根据不同的状态来在收缩和展开模型来回切换。状态定义如下:
<VisualStateManager.VisualStateGroups> <VisualStateGroup x:Name="CommonStates"> <VisualState x:Name="Initial"> <Storyboard > <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PART_PanelPresenter" Storyboard.TargetProperty="Status"> <DiscreteObjectKeyFrame KeyTime="0"> <DiscreteObjectKeyFrame.Value> <circleMenu:CircleMenuStatus>Initial</circleMenu:CircleMenuStatus> </DiscreteObjectKeyFrame.Value> </DiscreteObjectKeyFrame> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState> <VisualState x:Name="Collapsed"> <Storyboard > <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PART_PanelPresenter" Storyboard.TargetProperty="Status"> <DiscreteObjectKeyFrame KeyTime="0"> <DiscreteObjectKeyFrame.Value> <circleMenu:CircleMenuStatus>Collapsed</circleMenu:CircleMenuStatus> </DiscreteObjectKeyFrame.Value> </DiscreteObjectKeyFrame> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState> <VisualState x:Name="Expanded"> <Storyboard > <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PART_PanelPresenter" Storyboard.TargetProperty="Status"> <DiscreteObjectKeyFrame KeyTime="0"> <DiscreteObjectKeyFrame.Value> <circleMenu:CircleMenuStatus>Expanded</circleMenu:CircleMenuStatus> </DiscreteObjectKeyFrame.Value> </DiscreteObjectKeyFrame> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState> </VisualStateGroup> </VisualStateManager.VisualStateGroups>
不同状态的切换通过Storyboard中的ObjectAnimationUsingKeyFrames控制PART_Presenter的Status。这个PART_Presenter是我们自定义的继承自ItemPresenter的一个类型的对象。控件以这个自定义的Presenter作为桥梁,外部通过VisualStateManager更改其Status这个依赖属性,而内部自定义的Panel可以绑定到这个Status属性,从而根据当前的状态来对其中的元素进行布局。
先来看看这个自定义的ItemPresenter:
public class CircleMenuItemsPresenter:ItemsPresenter { public static readonly DependencyProperty StatusProperty = DependencyProperty.Register( "Status", typeof (CircleMenuStatus), typeof (CircleMenuItemsPresenter), new PropertyMetadata(default(CircleMenuStatus))); public CircleMenuStatus Status { get { return (CircleMenuStatus) GetValue(StatusProperty); } set { SetValue(StatusProperty, value); } } public static readonly DependencyProperty AngleProperty = DependencyProperty.Register( "Angle", typeof(Double), typeof(CircleMenuItemsPresenter), new PropertyMetadata(360d)); public double Angle { get { return (Double)GetValue(AngleProperty); } set { SetValue(AngleProperty, value); } } }
很简单就是添加了作为Control和Panel桥梁的几个依赖属性。(最根本的原因还是自定义的Panel不能直接绑定到Control的依赖属性,最多只能绑定到其父级ItemPresenter)
在WinRT中ItemPresenter变成了密封类,我们没法像上面那个自定义一个ItemPresenter供Panel绑定。所以实现方式有了很大变化。以后的文章会细说
接着是ItemPresenter和Panel的声明:
<circleMenu:CircleMenuItemsPresenter x:Name="PART_PanelPresenter" Status="Initial" Angle="{TemplateBinding Angle}" /> <Setter Property="ItemsPanel"> <Setter.Value> <ItemsPanelTemplate> <circleMenu:CircleMenuPanel x:Name="CircleMenuPanel" AnimationDuration="{StaticResource CircleDuration}" AnimationDurationStep="0.2" Radius="100" Angle="{Binding Angle, RelativeSource={RelativeSource FindAncestor, AncestorType=circleMenu:CircleMenuItemsPresenter}}" PanelStatus="{Binding Status, RelativeSource={RelativeSource FindAncestor, AncestorType=circleMenu:CircleMenuItemsPresenter } }" /> </ItemsPanelTemplate> </Setter.Value> </Setter>
可以看到,如果想让这个自定的Panel绑定到外部(控件级)的依赖属性就需要通过ItemPresenter中转一下。
接着是整个控件最核心的一部分CircleMenuPanel这个自定义面板的实现,这个文件比较长,分段来看。
首先是一些依赖属性,在上面的XAML它们的身影也出现过一次。
public static readonly DependencyProperty AnimationDurationProperty = DependencyProperty.Register( "AnimationDuration", typeof(Duration), typeof(CircleMenuPanel), new PropertyMetadata(default(Duration))); public Duration AnimationDuration { get { return (Duration)GetValue(AnimationDurationProperty); } set { SetValue(AnimationDurationProperty, value); } } public static readonly DependencyProperty AnimationDurationStepProperty = DependencyProperty.Register( "AnimationDurationStep", typeof(double), typeof(CircleMenuPanel), new PropertyMetadata(0.3d)); public double AnimationDurationStep { get { return (double)GetValue(AnimationDurationStepProperty); } set { SetValue(AnimationDurationStepProperty, value); } } public static readonly DependencyProperty RadiusProperty = DependencyProperty.Register( "Radius", typeof(Double), typeof(CircleMenuPanel), new PropertyMetadata(50d)); public double Radius { get { return (Double)GetValue(RadiusProperty); } set { SetValue(RadiusProperty, value); } } public static readonly DependencyProperty AngleProperty = DependencyProperty.Register( "Angle", typeof(double), typeof(CircleMenuPanel), new PropertyMetadata(360d)); public double Angle { get { return (double)GetValue(AngleProperty); } set { SetValue(AngleProperty, value); } } public static readonly DependencyProperty PanelStatusProperty = DependencyProperty.Register( "PanelStatus", typeof(CircleMenuStatus), typeof(CircleMenuPanel), new PropertyMetadata(CircleMenuStatus.Initial, ReRender)); public CircleMenuStatus PanelStatus { get { return (CircleMenuStatus)GetValue(PanelStatusProperty); } set { SetValue(PanelStatusProperty, value); } } private static void ReRender(DependencyObject d, DependencyPropertyChangedEventArgs e) { var circelPanel = (CircleMenuPanel)d; circelPanel.InvalidateArrange(); }
值得注意的是,在PanelStatus变化时触发了一个回调函数用来实现在面板(控件)状态变化时的重绘。
接着就是和布局相关的两个方法:
protected override Size MeasureOverride(Size availableSize) { var s = base.MeasureOverride(availableSize); foreach (UIElement element in this.Children) { element.Measure(availableSize); } return availableSize; } //http://www.cnblogs.com/mantgh/p/4161142.html protected override Size ArrangeOverride(Size finalSize) { var cutNum = (int)Angle == 360 ? this.Children.Count : (this.Children.Count - 1); var degreesOffset = Angle / cutNum; var i = 0; foreach (ContentPresenter element in Children) { var elementRadius = element.DesiredSize.Width / 2.0; var elementCenterX = elementRadius; var elementCenterY = elementRadius; var panelCenterX = Radius - elementRadius; var panelCenterY = Radius - elementRadius; var degreesAngle = degreesOffset * i; var radianAngle = (Math.PI * degreesAngle) / 180.0; var x = this.Radius * Math.Sin(radianAngle); var y = -this.Radius * Math.Cos(radianAngle); var destX = x + finalSize.Width / 2 - elementCenterX; var destY = y + finalSize.Height / 2 - elementCenterY; switch (PanelStatus) { case CircleMenuStatus.Initial: ArrangeInitialElement(element, panelCenterX, panelCenterY); break; case CircleMenuStatus.Collapsed: ArrangeCollapseElement(i, element, panelCenterX, panelCenterY, elementCenterX, elementCenterY, destX, destY); break; case CircleMenuStatus.Expanded: ArrangeExpandElement(i, element, panelCenterX, panelCenterY, elementCenterX, elementCenterY, destX, destY); break; } ++i; } return finalSize; }
当然重点是在ArrangeOverride方法中,针对每个元素的操作先是经过一些列计算得到分布在圆周上的位置,然后根据面板状态分别调用3个方法进行实际位置布局。如果是初始状态,只需要放置于中点就可以了。如果是Collapsed,则将子元素由圆周移动回中心。反之如果是Expanded则将小球由中心逐渐移动到圆周。
三个实际布局方法见下:
private void ArrangeExpandElement(int idx, ContentPresenter element, double panelCenterX, double panelCenterY, double elementCenterX, double elementCenterY, double destX, double destY) { element.Arrange(new Rect(panelCenterX, panelCenterY, element.DesiredSize.Width, element.DesiredSize.Height)); var transGroup = element.RenderTransform as TransformGroup; Transform translateTransform, rotateTransform; if (transGroup == null) { element.RenderTransform = transGroup = new TransformGroup(); translateTransform = new TranslateTransform(); rotateTransform = new RotateTransform() { CenterX = elementCenterX, CenterY = elementCenterY }; transGroup.Children.Add(translateTransform); transGroup.Children.Add(rotateTransform); } else { translateTransform = transGroup.Children[0] as TranslateTransform; rotateTransform = transGroup.Children[1] as RotateTransform; } element.RenderTransformOrigin = new Point(0.5, 0.5); //if (i != 0) continue; var aniDuration = AnimationDuration + TimeSpan.FromSeconds(AnimationDurationStep * idx); translateTransform.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(0, destX - panelCenterX, aniDuration)); translateTransform.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(0, destY - panelCenterY, aniDuration)); rotateTransform.BeginAnimation(RotateTransform.CenterXProperty, new DoubleAnimation(0, destX - panelCenterX, aniDuration)); rotateTransform.BeginAnimation(RotateTransform.CenterYProperty, new DoubleAnimation(0, destY - panelCenterY, aniDuration)); rotateTransform.BeginAnimation(RotateTransform.AngleProperty, new DoubleAnimation(0, 720, aniDuration)); element.BeginAnimation(OpacityProperty, new DoubleAnimation(0.2, 1, aniDuration)); } private void ArrangeInitialElement(ContentPresenter element, double panelCenterX, double panelCenterY) { element.Arrange(new Rect(panelCenterX, panelCenterY, element.DesiredSize.Width, element.DesiredSize.Height)); } private void ArrangeCollapseElement(int idx, ContentPresenter element, double panelCenterX, double panelCenterY, double elementCenterX, double elementCenterY, double destX, double destY) { element.Arrange(new Rect(destX, destY, element.DesiredSize.Width, element.DesiredSize.Height)); var transGroup = element.RenderTransform as TransformGroup; Transform translateTransform, rotateTransform; if (transGroup == null) { element.RenderTransform = transGroup = new TransformGroup(); translateTransform = new TranslateTransform(); rotateTransform = new RotateTransform() { CenterX = elementCenterX, CenterY = elementCenterY }; transGroup.Children.Add(translateTransform); transGroup.Children.Add(rotateTransform); } else { translateTransform = transGroup.Children[0] as TranslateTransform; rotateTransform = transGroup.Children[1] as RotateTransform; } element.RenderTransformOrigin = new Point(0.5, 0.5); //if (i != 0) continue; var aniDuration = AnimationDuration + TimeSpan.FromSeconds(AnimationDurationStep * idx); translateTransform.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(0, panelCenterX - destX, aniDuration)); translateTransform.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(0, panelCenterY - destY, aniDuration)); rotateTransform.BeginAnimation(RotateTransform.CenterXProperty, new DoubleAnimation(0, panelCenterX - destX, aniDuration)); rotateTransform.BeginAnimation(RotateTransform.CenterYProperty, new DoubleAnimation(0, panelCenterY - destY, aniDuration)); rotateTransform.BeginAnimation(RotateTransform.AngleProperty, new DoubleAnimation(0, -720, aniDuration)); element.BeginAnimation(OpacityProperty, new DoubleAnimation(1, 0.2, aniDuration)); }
透明动画是直接给子元素的Opacity属性施加了动画效果,而移动和旋转先组合为一个TransformGroup然后应用给子元素的RenderTransform。代码很容易懂,实现的时候注意下TranslateTransform的起至点坐标的计算和RotateTransform变化的旋转中心点的即可。特别是这个旋转中心点,其随着子元素“移动”过程也在不停的变化,从而使子元素总是相对于“当前”的中心在旋转。
到这里剩下的都比较简单了:
控件的代码:
[TemplatePart(Name = PartCenterBtn)] [TemplatePart(Name = PartContainer)] [TemplatePart(Name = PartPanelPresenter)] [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateInitial)] [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateExpanded)] [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateCollapsed)] public class CircleMenuControl : ItemsControl { private const string PartCenterBtn = "PART_CenterBtn"; private const string PartContainer = "PART_Container"; private const string PartPanelPresenter = "PART_PanelPresenter"; public const string VisualStateInitial = "Initial"; public const string VisualStateExpanded = "Expanded"; public const string VisualStateCollapsed = "Collapsed"; static CircleMenuControl() { DefaultStyleKeyProperty.OverrideMetadata(typeof(CircleMenuControl), new FrameworkPropertyMetadata(typeof(CircleMenuControl))); } #region dependency property public static readonly DependencyProperty AngleProperty = DependencyProperty.Register( "Angle", typeof(double), typeof(CircleMenuControl), new PropertyMetadata(360d)); public double Angle { get { return (double)GetValue(AngleProperty); } set { SetValue(AngleProperty, value); } } #endregion private Border _centerBtn; private Grid _container; private CircleMenuPanel _circleMenuPanel; private CircleMenuItemsPresenter _circleMenuItemsPresenter; public override void OnApplyTemplate() { if (_centerBtn != null) { _centerBtn.MouseLeftButtonUp -= centerBtn_Click; } base.OnApplyTemplate(); _centerBtn = GetTemplateChild(PartCenterBtn) as Border; _container = GetTemplateChild(PartContainer) as Grid; _circleMenuItemsPresenter = GetTemplateChild(PartPanelPresenter) as CircleMenuItemsPresenter; if (_centerBtn != null) { _centerBtn.MouseLeftButtonUp += centerBtn_Click; } } private void centerBtn_Click(object sender, RoutedEventArgs e) { //第一个参数是<VisualStateManager>所在元素的父元素,本控件中为Grid的父级,即控件本身 switch (_circleMenuItemsPresenter.Status) { case CircleMenuStatus.Expanded: VisualStateManager.GoToState(this, VisualStateCollapsed, false); break; case CircleMenuStatus.Initial: case CircleMenuStatus.Collapsed: VisualStateManager.GoToState(this, VisualStateExpanded, false); break; } //如果只是在控件内部更改Panel状态可以直接设置ItemPresenter的Status //使用VisualStateManager是为了可以在外部通过更改状态更新面板 } #region route event //inner menu click public static readonly RoutedEvent SubMenuClickEvent = ButtonBase.ClickEvent.AddOwner(typeof (CircleMenuControl)); public event RoutedEventHandler SubMenuClick { add { AddHandler(ButtonBase.ClickEvent, value, false); } remove { RemoveHandler(ButtonBase.ClickEvent, value); } } #endregion }
可以看到仍然是从ItemsControl集成来的控件。
这几行声明模板支持状态的代码可以告诉自定义控件模板的用户可以在模板中定义哪几种VisualState:
[TemplateVisualState(GroupName = "CommonStates", Name = VisualStateInitial)] [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateExpanded)] [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateCollapsed)]
在*按钮被点击的时候调用VisualStateManger.GoToState来切换控件状态。
private void centerBtn_Click(object sender, RoutedEventArgs e) { //第一个参数是<VisualStateManager>所在元素的父元素,本控件中为Grid的父级,即控件本身 switch (_circleMenuItemsPresenter.Status) { case CircleMenuStatus.Expanded: VisualStateManager.GoToState(this, VisualStateCollapsed, false); break; case CircleMenuStatus.Initial: case CircleMenuStatus.Collapsed: VisualStateManager.GoToState(this, VisualStateExpanded, false); break; } }
而子元素点击事件的发布和之前的控件处理方式差不多。
这里在控件中定义一个路由事件,处理子控件中没有被处理的Button.Click事件(这里选用了简单的实现方式限制子元素为Button):
#region route event //inner menu click public static readonly RoutedEvent SubMenuClickEvent = ButtonBase.ClickEvent.AddOwner(typeof (CircleMenuControl)); public event RoutedEventHandler SubMenuClick { add { AddHandler(ButtonBase.ClickEvent, value, false); } remove { RemoveHandler(ButtonBase.ClickEvent, value); } } #endregion
从控件使用的代码可以看到怎么订阅这个事件:
<circleMenu:CircleMenuControl ItemsSource="{Binding SubMenuItems}" Width="200" Height="200" BorderThickness="2" BorderBrush="Black"> <i:Interaction.Triggers> <i:EventTrigger EventName="SubMenuClick"> <command:EventToCommand Command="{Binding NodeClickCommand, Mode=OneWay}" PassEventArgsToCommand="True" /> </i:EventTrigger> </i:Interaction.Triggers> <circleMenu:CircleMenuControl.ItemTemplate> <DataTemplate> <Button> <Button.Template> <ControlTemplate TargetType="Button"> <Border CornerRadius="15" Background="Coral" Width="30" Height="30" > <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> </Border> </ControlTemplate> </Button.Template> <Button.Content> <TextBlock Text="{Binding Title}"></TextBlock> </Button.Content> </Button> </DataTemplate> </circleMenu:CircleMenuControl.ItemTemplate> </circleMenu:CircleMenuControl>
Item模板是一个自定义模板的Button,其未处理的事件会被向上传递触发控件的SubMenuClick事件。订阅事件还是借助MVVM Light中的EventToCommand这个方便的标签。事件处理Command:
private RelayCommand<RoutedEventArgs> _nodeClickCommand; public RelayCommand<RoutedEventArgs> NodeClickCommand { get { return _nodeClickCommand ?? (_nodeClickCommand = new RelayCommand<RoutedEventArgs>( p => { var dataItem = ((FrameworkElement)p.OriginalSource).DataContext; MessageBox.Show(((CircleMenuItem)dataItem).Id.ToString()); var circleCtrl = (CircleMenuControl)p.Source; var suc = VisualStateManager.GoToState(circleCtrl, CircleMenuControl.VisualStateCollapsed, false); var bb = 1; })); } }
最后为了完整性,把子元素用到的实体和绑定Items列表的代码也列到下面。这些和之前控件所介绍的基本一致。
public class CircleMenuItem { public CircleMenuItem() { } public CircleMenuItem(int id, string title,double offsetRate) { Id = id; Title = title; } public int Id { get; set; } public string Title { get; set; } } //ViewModel _dataService.GetData( (item, error) => { SubMenuItems = new ObservableCollection<CircleMenuItem>( new List<CircleMenuItem>() { new CircleMenuItem() {Id = 1, Title = "衣"}, new CircleMenuItem() {Id = 2, Title = "带"}, new CircleMenuItem() {Id = 3, Title = "渐"}, new CircleMenuItem() {Id = 4, Title = "宽"}, new CircleMenuItem() {Id = 5, Title = "终"}, new CircleMenuItem() {Id = 6, Title = "不"}, new CircleMenuItem() {Id = 7, Title = "悔"}, new CircleMenuItem() {Id = 8, Title = "为"}, new CircleMenuItem() {Id = 9, Title = "伊"}, new CircleMenuItem() {Id = 10, Title = "消"}, new CircleMenuItem() {Id = 11, Title = "得"}, new CircleMenuItem() {Id = 12, Title = "人"}, new CircleMenuItem() {Id = 13, Title = "憔"}, new CircleMenuItem() {Id = 14, Title = "悴"} }); }); private ObservableCollection<CircleMenuItem> _subMenuItems; public ObservableCollection<CircleMenuItem> SubMenuItems { get { return _subMenuItems; } set { Set(() => SubMenuItems, ref _subMenuItems, value); } }
基本上这个控件就是这样,大家多给意见。下面是干活
其他干货
在很长一段学习使用XAML系开发平台的过程中,逐步整理完善了一份Xmind文件,发出来供大家使用。像WPF系结构复杂,如果忘了什么可以看一个这个文档参考,可以省不少时间。
先上几张图,后面有下载地址
图1
图2
图3
代码下载
版权说明:本文版权归博客园和hystar所有,转载请保留本文地址。文章代码可以在项目随意使用,如果以文章出版物形式出现请表明来源,尤其对于博主引用的代码请保留其中的原出处尊重原作者劳动成果。