@
滾軸(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
控制元件將包含以下可繫結屬性:
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;
}
}
控制元件由進度條和進度文字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";
弧形滾軸的實現,與弧形進度條的實現類似,我們只需要在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)
最終效果如下:
Mato.Maui控制元件庫
Mato.Maui
本文來自部落格園,作者:林曉lx,轉載請註明原文連結:https://www.cnblogs.com/jevonsflash/p/17489161.html