[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
6. 原始碼
Kino.Toolkit.Wpf_ProgressRing at mas