[MAUI]弧形進度條與弧形滾軸的互動實現

2023-06-18 18:00:44

@


進度條(ProgressBar)用於展示任務的進度,告知使用者當前狀態和預期;

滾軸(Slider)通過拖動滾軸在一個固定區間內進行選擇數值範圍。

進度條和滾軸都是進度值在UI介面的對映,其中滾軸可以抽象成為帶控制柄(Thumb)的進度條,是介面元素和進度值的雙向繫結。

在某些場景下,我們需要一種更加直觀的進度條,比如弧形進度條。今天在MAUI中實現一個弧形進度條和滾軸。

使用.NET MAU實現跨平臺支援,本專案可執行於Android、iOS平臺。

弧形基礎類別

新建.NET MAUI專案,命名CircleWidget

在專案中新增SkiaSharp繪製功能的參照Microsoft.Maui.Graphics.Skia以及SkiaSharp.Views.Maui.Controls

<ItemGroup>
    <PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="7.0.59" />
    <PackageReference Include="SkiaSharp.Views.Maui.Controls" Version="2.88.3" />
</ItemGroup>

定義

對於弧形進度條的繪製,以及屬性定義等,我們將其抽象為一個基礎類別CircleProgressBase.cs,程式碼如下:

public abstract class CircleProgressBase : ContentView, IProgress

控制元件將包含以下可繫結屬性:

  • Maxiumum:最大值
  • Minimum:最小值
  • Progress:當前進度
  • AnimationLength:動畫時長
  • BorderWidth:描邊寬度
  • LabelContent:標籤內容
  • ContainerColor:容器顏色,即進度條的背景色
  • ProgressColor:進度條顏色
public abstract double Maximum { get; set; }
public abstract double Minimum { get; set; }
public abstract Color ContainerColor { get; set; }
public abstract Color ProgressColor { get; set; }

public abstract double Progress { get; set; }
public abstract double AnimationLength { get; set; }
public abstract double BorderWidth { get; set; }
public abstract View LabelContent { get; set; }

以及ValueChange事件,此事件用於在進度值改變時觸發。

public event EventHandler<double> ValueChanged;

實時進度值RealtimeProgress,應用於緩動動畫中的實時渲染,稍後會詳細說明。

protected double _realtimeProgress;

以及進度條寬度補償值,稍後會詳細說明。

protected float _mainRectPadding;

繪製弧

Skia中,通過AddArc方法繪製弧,需要傳入一個SKRect物件,其代表一個弧(或橢弧)的外接矩形。startAngle和sweepAngle分別代表順時針起始角度和掃描角度。

通過startAngle和sweepAngle可以繪製出一個弧,如下圖紅色部分所示:

在OnCanvasViewPaintSurface中,通過給定起始角度為正上方,掃描角度為360對於100%進度,通過插值計算出當前進度對應的掃描角度,繪製出進度條。

protected virtual void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
    SKImageInfo info = args.Info;
    SKSurface surface = args.Surface;
    SKCanvas canvas = surface.Canvas;
    canvas.Clear();
    SKRect rect = new SKRect(_mainRectPadding, _mainRectPadding, info.Width - _mainRectPadding, info.Height - _mainRectPadding);
    float startAngle = -90;
    float sweepAngle = (float)((_realtimeProgress / SumValue) * 360);

    canvas.DrawOval(rect, OutlinePaint);

    using (SKPath path = new SKPath())
    {
        path.AddArc(rect, startAngle, sweepAngle);

        canvas.DrawPath(path, ArcPaint);
    }
}

其中SumValue表明進度條的總進度,通過Maximum和Minimum計算得出。

public double SumValue => Maximum - Minimum;

建立進度條軌道背景畫刷和進度條畫刷:

protected SKPaint _outlinePaint;

public SKPaint OutlinePaint
{
    get
    {
        if (_outlinePaint == null)
        {
            RefreshMainRectPadding();
            SKPaint outlinePaint = new SKPaint
            {
                Color = this.ContainerColor.ToSKColor(),
                Style = SKPaintStyle.Stroke,
                StrokeWidth = (float)BorderWidth,
            };
            _outlinePaint = outlinePaint;
        }
        return _outlinePaint;
    }
}

protected SKPaint _arcPaint;

public SKPaint ArcPaint
{
    get
    {
        if (_arcPaint == null)
        {
            RefreshMainRectPadding();
            SKPaint arcPaint = new SKPaint
            {
                Color = this.ProgressColor.ToSKColor(),
                Style = SKPaintStyle.Stroke,
                StrokeWidth = (float)BorderWidth,
                StrokeCap = SKStrokeCap.Round,
            };
            _arcPaint = arcPaint;
        }

        return _arcPaint;
    }
}

弧形進度條(ProgressBar)

控制元件由進度條和進度文字Label組成,進度文字位於控制元件中心

建立CircleProgressBar,他將繼承CircleProgressBase,在Xaml部分我們新增弧形進度條的佈局,程式碼如下:

<?xml version="1.0" encoding="UTF-8"?>
<controls:CircleProgressBase xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                             xmlns:forms="clr-namespace:SkiaSharp.Views.Maui.Controls;assembly=SkiaSharp.Views.Maui.Controls"
                             xmlns:controls="clr-namespace:CircleWidget.Controls;assembly=CircleWidget"
                             x:Class="CircleWidget.Controls.CircleProgressBar">
    <controls:CircleProgressBase.Content>
        <Grid>
            <forms:SKCanvasView x:Name="canvasView"
                                PaintSurface="OnCanvasViewPaintSurface" />
            <ContentView x:Name="MainContent"></ContentView>
            <Label FontSize="28"
                   HorizontalOptions="Center"
                   VerticalOptions="Center"
                   x:Name="labelView"></Label>
        </Grid>

    </controls:CircleProgressBase.Content>
</controls:CircleProgressBase>

SKCanvasView是SkiaSharp.Views.Maui.Controls封裝的View控制元件。

效果如下

CodeBehind 中,我們將新增各抽象屬性的具體實現。

在Progress值變更時,重新渲染進度條,並觸發ValueChanged事件。


var obj = (CircleProgressBar)bindable;
obj.canvasView?.InvalidateSurface();
obj.ValueChanged?.Invoke(obj, obj.Progress);

新增動畫

我們在控制元件外部更改Progress值的時候,因為緩動函數的執行,進度條並未立即達到目標值,在此期間,_realtimeProgress值代表實時發生的進度值。

Progress值的變更,是一個「請求」,類似HeightRequest。完成動畫實際上是一個非同步過程。

新增函數UpdateProgressWithAnimate,當觸發Progress值變更請求時,呼叫此函數,將會執行動畫。


protected virtual void UpdateProgressWithAnimate(Action<double, bool> finished = null)
{
    this.AbortAnimation("ReshapeAnimations");
    var scaleAnimation = new Animation();


    double progressTarget = this.Progress;

    double progressOrigin = this._realtimeProgress;

    var animateAction = (double r) =>
    {
        this._realtimeProgress = r;
        ValueChanged?.Invoke(this, this._realtimeProgress);
    };
 
    var scaleUpAnimation0 = new Animation(animateAction, progressOrigin, progressTarget);
    scaleAnimation.Add(0, 1, scaleUpAnimation0);
    scaleAnimation.Commit(this, "ReshapeAnimations", 16, (uint)this.AnimationLength, finished: finished);

}

可以給動畫新增一個自定義緩動函數

如新增一個反覆彈跳至目標值的緩動函數,擬合函數影象如下:

應用到程式碼中:

var myEasing = (double x) => {
    if (x < 1 / 2.75f)
    {
        return 7.5625f * x * x;
    }
    if (x < 2 / 2.75f)
    {
        x -= 1.5f / 2.75f;
        return 7.5625f * x * x + .75f;
    }
    if (x < 2.5f / 2.75f)
    {
        x -= 2.25f / 2.75f;
        return 7.5625f * x * x + .9375f;
    }
    x -= 2.625f / 2.75f;
    return 7.5625f * x * x + .984375f;
};
var scaleUpAnimation0 = new Animation(animateAction, progressOrigin, progressTarget, myEasing);
scaleAnimation.Add(0, 1, scaleUpAnimation0);
scaleAnimation.Commit(this, "ReshapeAnimations", 16, (uint)this.AnimationLength, finished: finished);

在Progress值變更時的觸發函數改寫為:

var obj = (CircleSlider)bindable;
obj.UpdateProgressWithAnimate();

效果如下:

當然,這在每一次的變更時,都會應用動畫。如果頻繁密集地更改進度,這將會導致動畫的堆積,造成效能問題。

我們通過一個閾值限制動畫發生的頻次,當變更的進度值超過閾值時,才應用動畫。

CircleProgressBase 中新增一個常數:

protected const int ANIMATE_THROTTLE = 10;

當新值相較於舊值的變化幅度超過閾值時(10%或以上的進度變更請求),應用動畫,否則直接更新進度條。

protected virtual void UpdateProgress()
{
    this._realtimeProgress = this.Progress;
    ValueChanged?.Invoke(this, this._realtimeProgress);
}
var obj = (CircleSlider)bindable;
var valueChangedSpan = (double)oldValue - (double)newValue;
if (Math.Abs(valueChangedSpan) > ANIMATE_THROTTLE)
{
    obj.UpdateProgressWithAnimate();
}
else
{
    obj.UpdateProgress();
}

寬度補償

在Skia中,當我們設定path的寬度(StrokeWidth), path的繪製是以path的中心線為基準,向兩邊擴張的,如下圖

當預設繪製區域(canvas)的尺寸等同於控制元件尺寸時,繪製有可能溢位,為了保持繪製在控制元件內部,我們需要對繪製區域進行補償。

建立_mainRectPadding的更新函數RefreshMainRectPadding,當控制元件尺寸變更時


protected virtual void RefreshMainRectPadding()
{
    //邊界補償
    this._mainRectPadding = (float)(this.BorderWidth / 2);
    this.Padding = this._mainRectPadding;
}

當BorderWidth變更時,呼叫此函數,更新_mainRectPadding的值。

protected virtual void CircleProgressBar_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    ...
    if (e.PropertyName == nameof(BorderWidth))
    {
        this.RefreshMainRectPadding();
    }
}

文字

最後將進度文字控制元件值變更新增到CircleProgressBar_ValueChanged中,完成控制元件的實現。

private void CircleProgressBar_ValueChanged(object sender, double e)
{
    this.labelView.Text = e.ToString(LABEL_FORMATE);
    this.canvasView?.InvalidateSurface();
}

LABEL_FORMATE是一個常數,用於格式化進度文字的顯示。
string格式化請參考官方檔案

protected const string LABEL_FORMATE = "0";

弧形滾軸(Slider)

弧形滾軸的實現,與弧形進度條的實現類似,我們只需要在CircleProgressBar的基礎上,新增控制柄的佈局和拖動事件處理

建立CircleSlider,他將繼承CircleProgressBase,在Xaml部分,我們在原弧形進度條的佈局基礎上,新增弧形滾軸控制柄的佈局,程式碼如下:

<!-- 進度條佈局 -->
...

<!-- 控制柄佈局 -->
<ContentView x:Name="ThumbContent"
                Background="transparent"
                HeightRequest="50"
                WidthRequest="50">
    <ContentView.GestureRecognizers>
        <PanGestureRecognizer PanUpdated="PanGestureRecognizer_PanUpdated"></PanGestureRecognizer>
    </ContentView.GestureRecognizers>
    <Border Background="white"
            Opacity="0.5"
            StrokeThickness="0">
        <Border.StrokeShape>
            <RoundRectangle CornerRadius="50" />
        </Border.StrokeShape>
        <Border.Shadow>
            <Shadow Brush="Black"
                    Offset="20,20"
                    Radius="40"
                    Opacity="0.8" />
        </Border.Shadow>
    </Border>
</ContentView>

建立控制柄

重寫OnCanvasViewPaintSurface方法,新增控制柄的位置更新邏輯

protected override void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{

    ...
    var thumbX = Math.Sin(sweepAngle * Math.PI / 180) * (this.Width/2-1.25*this._mainRectPadding);
    var thumbY = Math.Cos(sweepAngle * Math.PI / 180) * (this.Height / 2-1.25*this._mainRectPadding);

    this.ThumbContent.TranslationX=thumbX;
    this.ThumbContent.TranslationY=-thumbY;

}

效果如下:

拖動事件處理

新增一個PanGestureRecognizer的事件處理常式,用於處理控制柄的拖動事件

首先計算觸控點的座標,以圓心為原點,觸控點的座標(PositionX,PositionY)是原ThumbContent的座標(TranslationX,TranslationY)與觸控點的偏移量(e.TotalX,e.TotalY)的和。

當控制柄被拖動時,我們需要計算出拖動的角度,觸控點與圓心的連線與X軸的夾角即為拖動的角度(sweepAngle)。

很容易得出,PositionX與PositionY的比值,是角度sweepAngle的正切值,他們的關係如下圖所示:

將角度轉換為進度值,更新進度條的值。

private void PanGestureRecognizer_PanUpdated(object sender, PanUpdatedEventArgs e)
{
    var thumb = sender as ContentView;
    var PositionX = thumb.TranslationX+e.TotalX;
    var PositionY = thumb.TranslationY+e.TotalY;

    this.test.TranslationX = thumb.TranslationX+e.TotalX;
    this.test.TranslationY = thumb.TranslationY+e.TotalY;

    var sweepAngle = AngleNormalize(Math.Atan2(PositionX, -PositionY)*180/Math.PI);

    var targetProgress = sweepAngle*SumValue/360;
    this.Progress=targetProgress;

}

sweepAngle的取值範圍為[-180,180],我們需要將其轉換為[0,360]的取值範圍,這裡我們使用AngleNormalize函數進行轉換。

private double AngleNormalize(double value)
{
    double twoPi = 360;
    while (value <= -180) value += twoPi;
    while (value >   180) value -= twoPi;
    value= (value + twoPi) % twoPi;
    return value;
}

將可繫結屬性Progress的繫結模式改為TwoWay。

public static readonly BindableProperty ProgressProperty =
BindableProperty.Create("Progress", typeof(double), typeof(CircleSlider), 0.5, defaultBindingMode:BindingMode.TwoWay)

最終效果如下:

專案地址

Github:maui-samples

Mato.Maui控制元件庫
Mato.Maui