原文:[WPF自定义控件库] 模仿UWP的ProgressRing
1. 为什么需要ProgressRing#
虽然我认为这个控件库的控件需要模仿Aero2的外观,但总有例外,其中一个就是ProgressRing。ProgressRing是来自UWP的控件,部分代码参考了 这里。ProgressRing的使用方式运行效果如下:
<kino:ProgressRing IsActive="True" Height="40" Width="40" Margin="8" MinHeight="9" MinWidth="9" />
在Windows 10中ProgressRing十分常见,而且十分好用。它还支持自适应尺寸,在紧凑的地方使用ProgressRing会给UI增色不少,而且不会显得格格不入:
那为什么不使用ProgressBar?其中一个原因是ProgressBar功能太多,而我很多时候只需要一个简单的显示正在等待的元素,另一个原因是条状的ProgressBar在紧凑的地方不好看,所以才需要结构相对简单的ProgressRing。
2. 基本结构#
[TemplateVisualState(GroupName = VisualStates.GroupActive, Name = VisualStates.StateActive)] [TemplateVisualState(GroupName = VisualStates.GroupActive, Name = VisualStates.StateInactive)] public partial class ProgressRing : Control { // Using a DependencyProperty as the backing store for IsActive. This enables animation, styling, binding, etc... public static readonly DependencyProperty IsActiveProperty = DependencyProperty.Register("IsActive", typeof(bool), typeof(ProgressRing), new PropertyMetadata(false, new PropertyChangedCallback(IsActiveChanged))); private bool hasAppliedTemplate = false; public ProgressRing() { DefaultStyleKey = typeof(ProgressRing); } public bool IsActive { get { return (bool)GetValue(IsActiveProperty); } set { SetValue(IsActiveProperty, value); } } public override void OnApplyTemplate() { base.OnApplyTemplate(); hasAppliedTemplate = true; UpdateState(IsActive); } private static void IsActiveChanged(DependencyObject d, DependencyPropertyChangedEventArgs args) { var pr = (ProgressRing)d; var isActive = (bool)args.NewValue; pr.UpdateState(isActive); } private void UpdateState(bool isActive) { if (hasAppliedTemplate) { string state = isActive ? VisualStates.StateActive : VisualStates.StateInactive; VisualStateManager.GoToState(this, state, true); } } }
ProgressRing的基本代码如上所示,它只包含IsActive这个属性,并使用这个属性控制它在Active和Inactive两种状态之间切换。参考Silverlight Toolkit,我也把常用的各种VisualState的状态名称作为常量写到一个统一的VisualStates
类里:
#region GroupActive /// <summary> /// Active state. /// </summary> public const string StateActive = "Active"; /// <summary> /// Inactive state. /// </summary> public const string StateInactive = "Inactive"; /// <summary> /// Active state group. /// </summary> public const string GroupActive = "ActiveStates"; #endregion GroupActive
3. 旋转#
XAML部分几乎全部照抄UWP的ProgressRing,所以实际运行效果和UWP的ProgressRing很像,区别很小。
通常来说,ProgressRing的Active状态持续时间不会太长,而且ProgressRing的尺寸也不会太大,所以ProgressRing的Active状态可以说不计成本。Active状态下有5个Ellipse 不停旋转,或者说做绕着中心点做圆周运动,而为了不需要任何计算圆周中心点的代码,ProgressRing给每个Ellipse外面都套上一个Canvas,让这整个Canvas旋转。XAML大概这样:
<Storyboard RepeatBehavior="Forever" x:Key="Sb"> <DoubleAnimationUsingKeyFrames Storyboard.TargetName="E1R" BeginTime="0" Storyboard.TargetProperty="Angle"> <SplineDoubleKeyFrame KeyTime="0" Value="-110" KeySpline="0.13,0.21,0.1,0.7" /> <SplineDoubleKeyFrame KeyTime="0:0:0.433" Value="10" KeySpline="0.02,0.33,0.38,0.77" /> <SplineDoubleKeyFrame KeyTime="0:0:1.2" Value="93" /> <SplineDoubleKeyFrame KeyTime="0:0:1.617" Value="205" KeySpline="0.57,0.17,0.95,0.75" /> <SplineDoubleKeyFrame KeyTime="0:0:2.017" Value="357" KeySpline="0,0.19,0.07,0.72" /> <SplineDoubleKeyFrame KeyTime="0:0:2.783" Value="439" /> <SplineDoubleKeyFrame KeyTime="0:0:3.217" Value="585" KeySpline="0,0,0.95,0.37" /> </DoubleAnimationUsingKeyFrames> </Storyboard> <Canvas RenderTransformOrigin=".5,.5" Height="100" Width="100"> <Canvas.RenderTransform> <RotateTransform x:Name="E1R" /> </Canvas.RenderTransform> <Ellipse x:Name="E1" Width="20" Height="20" Fill="MediumPurple" /> </Canvas>
然后运行效果这样:
4. 自适应大小#
为了让ProgressRing中各个Ellipse都可以自适应大小,ProgressRing提供了一个TemplateSettings
属性,类型为TemplateSettingValues
,它里面包含以下记个依赖属性:
public double MaxSideLength { get { return (double)GetValue(MaxSideLengthProperty); } set { SetValue(MaxSideLengthProperty, value); } } public double EllipseDiameter { get { return (double)GetValue(EllipseDiameterProperty); } set { SetValue(EllipseDiameterProperty, value); } } public Thickness EllipseOffset { get { return (Thickness)GetValue(EllipseOffsetProperty); } set { SetValue(EllipseOffsetProperty, value); } }
XAML中的元素大小及布局绑定到这些属性:
<Grid x:Name="Ring" Background="{TemplateBinding Background}" MaxWidth="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.MaxSideLength}" MaxHeight="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.MaxSideLength}" Visibility="Collapsed" RenderTransformOrigin=".5,.5" FlowDirection="LeftToRight"> <Canvas RenderTransformOrigin=".5,.5"> <Canvas.RenderTransform> <RotateTransform x:Name="E1R" /> </Canvas.RenderTransform> <Ellipse x:Name="E1" Style="{StaticResource ProgressRingEllipseStyle}" Width="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.EllipseDiameter}" Height="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.EllipseDiameter}" Margin="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.EllipseOffset}" Fill="{TemplateBinding Foreground}" /> </Canvas>
每当ProgressRing调用MeasureOverrride
都重新计算这些值:
protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize) { var width = 20d; var height = 20d; if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(this) == false) { width = double.IsNaN(Width) == false ? Width : availableSize.Width; height = double.IsNaN(Height) == false ? Height : availableSize.Height; } TemplateSettings = new TemplateSettingValues(Math.Min(width, height)); return base.MeasureOverride(availableSize); }
public TemplateSettingValues(double width) { if (width <= 40) { EllipseDiameter = (width / 10) + 1; } else { EllipseDiameter = width / 10; } MaxSideLength = width - EllipseDiameter; EllipseOffset = new System.Windows.Thickness(0, EllipseDiameter * 2.5, 0, 0); }
这样就实现了外观的自适应大小功能。需要注意的是,过去很多人喜欢将这种重新计算大小的操作放到LayoutUpdated
事件中进行,但LayoutUpdated
是整个布局的最后一步,这时候如果改变了控件的大小有可能重新触发Measure和Arrange及LayoutUpdated
,这很可能引起“布局循环”的异常。正确的做法是将计算尺寸及改变尺寸的操作都放到最初的MeasureOverride
中。
TemplateSettings在UWP中很长见到,它的其它用法可以参考这篇文章:了解模板化控件:UI指南
5. 参考#
brian dunnington - ProgressRing for Windows Phone 8
FrameworkElement.MeasureOverride(Size) Method (System.Windows) Microsoft Docs.html
UIElement.InvalidateMeasure Method (System.Windows) Microsoft Docs
UIElement.IsMeasureValid Property (System.Windows) Microsoft Docs
UIElement.LayoutUpdated Event (System.Windows) Microsoft Docs