1. 程式人生 > >[UWP]建立一個進度按鈕

[UWP]建立一個進度按鈕

原文: [UWP]建立一個進度按鈕

1. 前言

最近想要一個進度按鈕。

傳統上UWP上處理進度可以這樣實現,首先是XAML,包括一個ProgressBar和一個按鈕:

<StackPanel Orientation="Horizontal" Margin="0,30" >
    <ProgressBar x:Name="ProgressBar" Maximum="1" Width="230"/>
    <Button x:Name="Button" Content="Download"
            Click="OnStartProgress" Margin="20,0,0,0"/>
</StackPanel>

然後是服務端,假設我有這樣一個服務:

public class TestService
{
    public event EventHandler<double> ProgressChanged;
    
    public async Task Start(bool throwException = false)
    {
        IsStarted = true;
        try
        {
            ProgressChanged?.Invoke(this, _progress);
            await Task.Delay(1000);
            while (_progress < 1)
            {
                await Task.Delay(100);
                _progress += 0.03;
                ProgressChanged?.Invoke(this, _progress);
                if (_progress > 0.7 && throwException)
                    throw new Exception("test");

                if (IsPaused)
                    return;
            }

            IsCompleted = true;
        }
        finally
        {
            IsStarted = false;
        }
    }
}

接下來就是用程式碼處理:

private async void OnStartProgress(object sender, Windows.UI.Xaml.RoutedEventArgs e)
{
    Button.Visibility = Windows.UI.Xaml.Visibility.Collapsed;
    try
    {
        var uiSettings = new Windows.UI.ViewManagement.UISettings();
        Windows.UI.Color color = uiSettings.GetColorValue(UIColorType.Accent);
        var brush = new SolidColorBrush(color);
        ProgressBar.Foreground = brush;
        var testService = new TestService();
        testService.ProgressChanged += (s, args) => { ProgressBar.Value = args; };
        await testService.Start(ThrowExceptionElement.IsOn);
    }
    catch (Exception ex)
    {
        var brush = new SolidColorBrush(Colors.PaleVioletRed);
        ProgressBar.Foreground = brush;
    }
    finally
    {
        Button.Visibility = Windows.UI.Xaml.Visibility.Visible;
    }
   
}

點選按鈕開始進度,隱藏按鈕;進度完成後重新顯示按鈕。執行效果如下:

出錯的時候將ProgressBar的Foreground設定成紅色。這裡偷懶用程式碼處理,其實用VisualState處理會更好。效果如下:

基本上這樣就夠用了,Windows 10裡通常也是幾個按鈕配合ProgressBar來實現進度的控制。但這樣做XAML部分不能複用,同時管理Button和ProgressBar也比較複雜,在空間有侷限的地方也不能使用。

結果還是自己做了個ProgressButton來用。

2. 成果

ProgressButton實現了上述UI的功能:

如上圖所示,ProgressButton只在幾種狀態間轉換:

  • Ready,普通的狀態,因為“Normal”已經被“CommonStates”佔用,用“Ready”還算比較合適。
  • Started,開始的狀態(說不定“InProgress”比較合適)。
  • Completed,完成的狀態。
  • Faulted,出錯的狀態。

本來還應該有Paused狀態,但還沒想好UI上應該怎麼呈現,因為Paused狀態下應該有Cancel和Restart兩種動作(可以參考下圖應用商店的下載頁面),在一個按鈕上不容易同時呈現這兩種動作。而且暫時還不需要這個功能,這次就不實現了。

3. 實現

通常我建議先寫完所有程式碼,再用Blend實現UI,這樣會比在程式碼和UI間交錯地工作更高效。

3.1 處理程式碼

ProgressButton 的基本程式碼如下(不包含依賴屬性和const string等內容):

[TemplateVisualState(GroupName = ProgressStatesGroupName, Name = ReadyStateName)]
[TemplateVisualState(GroupName = ProgressStatesGroupName, Name = StartedStateName)]
[TemplateVisualState(GroupName = ProgressStatesGroupName, Name = CompletedStateName)]
[TemplateVisualState(GroupName = ProgressStatesGroupName, Name = FaultedStateName)]
public partial class ProgressButton : Button
{
    public ProgressButton()
    {
        this.DefaultStyleKey = typeof(ProgressButton);
        this.Click += OnClick;
    }

    public ProgressState State
    {
        get { return (ProgressState)GetValue(StateProperty); }
        set { SetValue(StateProperty, value); }
    }

    public double Progress
    {
        get { return (double)GetValue(ProgressProperty); }
        set { SetValue(ProgressProperty, value); }
    }

    public event EventHandler StateChanged;
    public event EventHandler<ProgressStateChangingEventArgs> StateChanging;

    protected override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        UpdateVisualStates(false);
    }

    protected virtual void OnStateChanged(ProgressState oldValue, ProgressState newValue)
    {
        if (newValue == ProgressState.Ready)
            Progress = 0;

        UpdateVisualStates(true);

        StateChanged?.Invoke(this, EventArgs.Empty);
    }

    protected virtual void OnProgressChanged(double oldValue, double newValue)
    {
        if (newValue < 0)
            Progress = 0;

        if (newValue > 1)
            Progress = 1;
    }

    private void OnClick(object sender, RoutedEventArgs e)
    {
        switch (State)
        {
            case ProgressState.Ready:
                ChangeStateCore(ProgressState.Started);
                break;
            case ProgressState.Started:
                ChangeStateCore(ProgressState.Ready);
                break;
            case ProgressState.Completed:
                ChangeStateCore(ProgressState.Ready);
                break;
            case ProgressState.Faulted:
                ChangeStateCore(ProgressState.Ready);
                break;
        }
    }

    private void UpdateVisualStates(bool useTransitions)
    {
        string progressState;
        switch (State)
        {
            case ProgressState.Ready:
                progressState = ReadyStateName;
                break;
            case ProgressState.Started:
                progressState = StartedStateName;
                break;
            case ProgressState.Completed:
                progressState = CompletedStateName;
                break;
            case ProgressState.Faulted:
                progressState = FaultedStateName;
                break;
            default:
                progressState = ReadyStateName;
                break;
        }
        VisualStateManager.GoToState(this, progressState, useTransitions);
    }

    private void ChangeStateCore(ProgressState newstate)
    {

        var args = new ProgressStateChangingEventArgs(this.State, newstate);
        if (args.OldValue == ProgressState.Started && args.NewValue == ProgressState.Ready)
            args.Cancel = true;

        OnStateChanging(args);
        StateChanging?.Invoke(this, args);
        if (args.Cancel)
            return;

        State = newstate;
    }

    protected virtual void OnStateChanging(ProgressStateChangingEventArgs args)
    {

    }

}

ProgressButton直接繼承Button,並且包含如下功能:

  • 包含 public ProgressState Statepublic double Progress兩個屬性。
  • 使用TemplateVisualState聲明瞭控制元件模板對應四種狀態的VisualState。
  • 處理Click事件在各個狀態之間切換,通過EventHandler StateChanged通知使用者State改變的結果,並且使用EventHandler StateChanging 為使用者提供了控制這個過程的途徑。

基本使用方式如下:

private async void OnStateChanged(object sender, EventArgs e)
{
    switch (ProgressButton.State)
    {
        case ProgressState.Started:
            try
            {
                var testService = new TestService();
                testService.ProgressChanged += (s, args) => { ProgressButton.Progress = args; };
                await testService.Start(ThrowExceptionElement.IsOn);
                ProgressButton.State = ProgressState.Completed;
            }
            catch (Exception)
            {
                ProgressButton.State = ProgressState.Faulted;
            }
            break;
    }
}

ProgressButton的程式碼量不多,功能上已滿足我目前的需求。

3.2 處理UI

接來下處理UI,處理UI的原則是不要為了UI上的任何功能修改ProgressButton.cs,避免UI和程式碼間的耦合。

3.2.1 原理

如前所示,ProgressButton將一個矩形的按鈕轉變成圓形,再在圓形的邊框上顯示進度。這兩個功能的實現方式在以前的文章中有介紹過。

實用的Shape指南中介紹了Rectangle的public System.Double RadiusX { get; set; }public System.Double RadiusY { get; set; }分別用於指定用於使矩形的角變圓的橢圓的x軸和 y軸半徑。只要把Rectangle的寬高設成一致,RadiusX和RadiusY設成寬高的一半,Rectangle看上去就成了一個普通的Ellipse。下圖展示了 RadiusX="50" RadiusY="20"的Rectangle的圓角和Width="100" Height="40"的Ellipse(x軸半徑50,y軸半徑20)基本重合在一起。:

用Shape做動畫中介紹了怎麼使用StrokeDashArray做進度提示動畫:

理解及擴充套件Expander中介紹了怎麼對StackPanel做拉伸動畫,只是這次為了讓內容可以變形將StackPanel換成Grid:

在ProgressButton的ControlTemplate中這三個功能都用Behavior做成動畫了,同樣在用Shape做動畫介紹了怎麼使用Behavior。(最近常常用Behavior,簡直走火入魔。)

這麼看來ProgressButton完全是以前介紹過的技術的組合應用,幾乎沒有新知識。

3.2.2 假裝成普通Button

UWP的Button的ControlTemplate中只有一個ContentPresenter,邊框、背景等都由這個ContentPresenter呈現。ProgressButton為了對邊框和背景變形,移除了ContentPresenter的這部分內容,改為由一個Rectangle呈現:

<Rectangle x:Name="Rectangle"
           StrokeThickness="{Binding RelativeSource={RelativeSource Mode=TemplatedParent},Path=BorderThickness,Converter={StaticResource BorderToStrokeThicknessConverter}}"
           Stroke="{TemplateBinding BorderBrush}"
           Fill="{TemplateBinding Background}">

由於Thickness BorderThicknessdouble StrokeThickness的型別不匹配,所以用BorderToStrokeThicknessConverter轉換。

3.2.3 ControlTemplate結構

如上圖所示,除了Rectangle,還另外添加了顯示進度的Ellipse,顯示Completed狀態的CompletedElement和顯示Faulted狀態的FaultedElement。其實後面兩個元素可以交由Rectangle處理,但我的Blend出了問題不能編輯ControlTemplate,ProgressButton所有動畫都要手寫,這樣實現方式就有了很大限制,多了兩個Element雖然結構變複雜,但控制它們只需要對Opacity做動畫,還算比較輕鬆。反正Button只是小小的一塊元素,就算結構再複雜對整體效能影響有限,我不會太介意這點複雜性。

3.2.4 FontIcon

<FontIcon Glyph="&#xE001;"
          Foreground="White"
          FontSize="{TemplateBinding FontSize}"
          x:Name="CompletedIcon" />

CompletedElement和FaultedElement中的圖示(√和×)使用了FontIcon,並且FontSize通過TemplateBinding綁定了FontSize,這樣的好處是這兩個圖示的大小可以和按鈕的字型保持一致。其實反正是向量元素,用Path再配合ViewBox也可以達到同樣效果,但只是簡單圖案的話使用FontIcon明顯簡潔方便多了。

3.2.5 DropShadowPanel

CompletedElement和FaultedElement裡都用上了DropShadowPanel,這樣UI上好看一點點。UWP中的Ellipse常常能看到鋸齒,使用帶圓角的元素時要注意這點,適當使用DropShadow能讓鋸齒看上去不那麼明顯,這是我常用的小技巧。在WPF中陰影效果對效能影響很大,而且應用陰影效果的元素尺寸越大對效能的影響就越大。但Silverlight以後效能影響就變小了,我沒測試過UWP的情況,應該不會比Silverlight差吧。何況按鈕的尺寸基本都不大,就算再怎麼亂來對效能影響都有限。

3.2.6 VisualState

如果Blend沒出錯應該可以看到上圖的所有狀態。其中FocusStates基本上不會去處理。ProgressButton在Button的ControlTemplate基礎上添加了ProgressStates。雖然ProgressButton中按鈕的基本功能不是重點,但還是需要細心處理CommonStates的各種狀態。

4. 其它

由於UWP的元素基本是向量元素,ProgressButton也得益於這個優點,在狹窄空間也能表現得很好,配合StateChanged和StateChanging事件可以擴充套件更多的用法:

另外,雖然沒有Paused狀態,但配合ProgressBar和StateChanging事件,還是可以實現Paused-Restar的基本功能:

<Grid >
    <Grid.ColumnDefinitions>
        <ColumnDefinition />
        <ColumnDefinition Width="Auto" />
    </Grid.ColumnDefinitions>

    <ContentControl Content="{Binding}"
                    Margin="5,0"
                    VerticalAlignment="Center" />

    <local:ProgressButton Content="download"
                          x:Name="ProgressButton"
                          Margin="5,0"
                          Grid.Column="1"
                          HorizontalAlignment="Right"
                          StateChanged="OnCase3StateChanged"
                          StateChanging="OnCase3StateChanging" 
                         />
    <ProgressBar Grid.ColumnSpan="2"
                 Maximum="1"
                 ShowPaused="{Binding ElementName=ProgressButton,Path=State,Converter={StaticResource ProgressStateToPausedConverter}}"
                 Value="{Binding ElementName=ProgressButton,Path=Progress}"
                 Style="{StaticResource ProgressBarStyle1}"
                 Foreground="#1D0490FF"
                 VerticalContentAlignment="Stretch"
                 VerticalAlignment="Stretch" 
                 IsHitTestVisible="False"/>
</Grid>

5. 結語

做完後才有點後悔,其實ProgressButton不應該繼承Button,既然不是Button好像也不應該命名為-Button。如果繼承自ProgressBar的話可以直接使用它的Minimum和Maximum,Progress也不用限定在0到1之間。

由於UWP沒有Resizing動畫,ProgressButton改變寬度的動畫實現得不算很好,從上面可以看到即使內容從'download'變成'open',ProgressButton的寬度還是'download'的寬度,這是ProgressButton的另一個遺憾。

順便一提,雖然沒有測試過但我想大部分程式碼可以相容WPF。

6. 參考

How to Create a Circular Progress Button.htm

7. 原始碼

Progress Button Sample