1. 程式人生 > >[WPF自定義控制元件庫] 模仿UWP的ProgressRing

[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