繼之前那個控件,又做了一個原理差不多的控件。這個控件主要模仿百度貼吧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所有,轉載請保留本文地址。文章代碼可以在項目隨意使用,如果以文章出版物形式出現請表明來源,尤其對於博主引用的代碼請保留其中的原出處尊重原作者勞動成果。
