[MAUI]模仿嗶哩嗶哩的一鍵三連

林晓lx發表於2024-03-25

@

目錄
  • 建立弧形進度條
    • 繪製弧
  • 準備物料
  • 建立氣泡
  • 建立手勢
  • 建立互動與動效
  • 專案地址

嗶哩嗶哩(Bilibili)中使用者可以透過長按點贊鍵同時完成點贊、投幣、收藏對UP主表示支援,後UP主多用“一鍵三連”向影片瀏覽者請求對其作品同時進行點贊、投幣、收藏。

在這裡插入圖片描述

“三連按鈕”是一組按鈕,輕擊時當做普通狀態按鈕使用,當長按 2 秒鐘後,轉為三連模式,可以控制並顯示進度,並在進度完成時彈出一些泡泡

一直想實現這個效果,但由於.NET MAUI對圖形填充渲染問題直到.NET 8才修復。想要的效果直到最近才能實現。

兩年前Dino老師用UWP實現過“一鍵三連”:

[UWP]模仿嗶哩嗶哩的一鍵三連

今天用MAUI實現。

在這裡插入圖片描述

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

建立弧形進度條

新建.NET MAUI專案,命名HoldDownButtonGroup

在專案中新增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>

進度條(ProgressBar)用於展示任務的進度,告知使用者當前狀態和預期,本專案依賴弧形進度條元件(CircleProgressBar),此元件在本專案中用於展示“三連按鈕”長按任務的進度

這裡簡單介紹弧形進度條元件的原理和實現,更多內容請閱讀:[MAUI]弧形進度條與弧形滑塊的互動實現

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

  • Maxiumum:最大值
  • Minimum:最小值
  • Progress:當前進度
  • AnimationLength:動畫時長
  • BorderWidth:描邊寬度
  • LabelContent:標籤內容
  • ContainerColor:容器顏色,即進度條的背景色
  • ProgressColor:進度條顏色

建立CircleProgressBar.xaml,程式碼如下:

<?xml version="1.0" encoding="UTF-8"?>
<ContentView 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"
             x:Class="HoldDownButtonGroup.Controls.CircleProgressBar">
    <ContentView.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>

    </ContentView.Content>
</ContentView>

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

繪製弧

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

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

在這裡插入圖片描述

CircleProgressBar.xaml.cs的CodeBehind中,建立OnCanvasViewPaintSurface,透過給定起始角度為正上方,掃描角度為360對於100%進度,透過插值計算出當前進度對應的掃描角度,繪製出進度條。

private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{

    var SumValue = Maximum - Minimum;


    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;

建立進度條軌道背景畫刷和進度條畫刷,其中進度條畫刷的StrokeCap屬性設定為SKStrokeCap.Round,使得進度條兩端為圓形。

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;
    }
}

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


private void UpdateProgress()
{
    this._realtimeProgress = this.Progress;
    this.labelView.Text = this.Progress.ToString(LABEL_FORMATE);
    this.canvasView?.InvalidateSurface();
}

效果如下

在這裡插入圖片描述

準備物料

點贊、投幣、收藏三個按鈕的圖片來自於嗶哩嗶哩(Bilibili)網站。這些按鈕用svg格式在html中。

我們只需前往嗶哩嗶哩主站,要開啟瀏覽器的開發者工具,用元素檢查器,在找到按鈕位置後檢視其樣式,複製path中的svg程式碼,即可得到這些向量圖片。

複製右側紅色區域選中的部分,我們只需要這些svg程式碼。

在Xaml中我們建立Path元素,並設定Data屬性為svg程式碼。即可得到一個圖形

<Path HeightRequest="65"
        WidthRequest="65"
        x:Name="Icon1"
        Fill="Transparent"
        Aspect="Uniform"
        Data="M 9.77234 30.8573 V 11.7471 H 7.54573 C 5.50932 11.7471 3.85742 13.3931 3.85742 15.425 V 27.1794 C 3.85742 29.2112 5.50932 30.8573 7.54573 30.8573 H 9.77234 Z M 11.9902 30.8573 V 11.7054 C 14.9897 10.627 16.6942 7.8853 17.1055 3.33591 C 17.2666 1.55463 18.9633 0.814421 20.5803 1.59505 C 22.1847 2.36964 23.243 4.32583 23.243 6.93947 C 23.243 8.50265 23.0478 10.1054 22.6582 11.7471 H 29.7324 C 31.7739 11.7471 33.4289 13.402 33.4289 15.4435 C 33.4289 15.7416 33.3928 16.0386 33.3215 16.328 L 30.9883 25.7957 C 30.2558 28.7683 27.5894 30.8573 24.528 30.8573 H 11.9911 H 11.9902 Z"
        VerticalOptions="Center"
        HorizontalOptions="Center" />

在這裡插入圖片描述

建立氣泡

氣泡實現分為兩個步驟:

Bubble.xaml 建立單一氣泡動畫
Bubbles.xaml 包含氣泡叢集。隨機生成氣泡動畫路徑,建立氣泡叢集的動畫

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

  • Brush:氣泡顏色
  • BubbleCnt:氣泡數量
  • BubbleSize:氣泡大小

氣泡動畫演算法參考於火火的 BubbleButton,這裡只帖關鍵程式碼

單一氣泡的動畫:先變大後消失

public Animation GetAnimation()
{

    var scaleAnimation = new Animation();

    var scaleUpAnimation0 = new Animation(v => MainBox.Scale = v, 0, 1);
    var scaleUpAnimation1 = new Animation(v => MainBox.Scale = v, 1, 0);


    scaleAnimation.Add(0, 0.2, scaleUpAnimation0);
    scaleAnimation.Add(0.8, 1, scaleUpAnimation1);

    scaleAnimation.Finished = () =>
    {
        this.MainBox.Scale = 0;
    };

    return scaleAnimation;

}

生成氣泡

public void SpawnBubbles()
{
    this.PitContentLayout.Clear();
    for (int i = 0; i < BubbleCnt; i++)
    {
        var currentBox = new Bubble();
        currentBox.FillColor = i % 2 == 0 ? this.Brush : SolidColorBrush.Transparent;
        currentBox.BorderColor = this.Brush;
        currentBox.HeightRequest = BubbleSize;
        currentBox.WidthRequest = BubbleSize;
        currentBox.HorizontalOptions = LayoutOptions.Start;
        currentBox.VerticalOptions = LayoutOptions.Start;
        this.PitContentLayout.Add(currentBox);
    }
}

計算單個氣泡的動畫路徑:隨機產生動畫運動的隨機座標

private Animation InitAnimation(Bubble element, Size targetSize, bool isOnTop = true)
{


    var offsetAnimation = new Animation();

    if (targetSize == default)
    {
        targetSize = element.DesiredSize;

    }
    var easing = Easing.Linear;

    var originX = PitContentLayout.Width / 2;
    var originY = PitContentLayout.Height / 2;

    var targetX = rnd.Next(-(int)targetSize.Width, (int)targetSize.Width) + (int)targetSize.Width / 2 + originX;
    var targetY = isOnTop ? rnd.Next(-(int)(targetSize.Height * 1.5), 0) + (int)targetSize.Height / 2 + originY :
            rnd.Next(0, (int)(targetSize.Height * 1.5)) + (int)targetSize.Height / 2 + originY
        ;


    var offsetX = targetX - originX;
    var offsetY = targetY - originY;


    var offsetAnimation1 = new Animation(v => element.TranslationX = v, originX - targetSize.Width / 2, targetX - targetSize.Width / 2, easing);
    var offsetAnimation2 = new Animation(v => element.TranslationY = v, originY - targetSize.Height / 2, targetY - targetSize.Height / 2, easing);

    offsetAnimation.Add(0.2, 0.8, offsetAnimation1);
    offsetAnimation.Add(0.2, 0.8, offsetAnimation2);
    offsetAnimation.Add(0, 1, element.BoxAnimation);

    offsetAnimation.Finished = () =>
    {

        element.TranslationX = originX;
        element.TranslationY = originY;
        element.Rotation = 0;
    };

    return offsetAnimation;
}

開始氣泡動畫

public void StartAnimation()
{

    Content.AbortAnimation("ReshapeAnimations");
    var offsetAnimationGroup = new Animation();

    foreach (var item in this.PitContentLayout.Children)
    {
        if (item is Bubble)
        {
            var isOntop = this.PitContentLayout.Children.IndexOf(item) > this.PitContentLayout.Children.Count / 2;
            var currentAnimation = InitAnimation(item as Bubble, targetSize, isOntop);
            offsetAnimationGroup.Add(0, 1, currentAnimation);


        }
    }
    offsetAnimationGroup.Commit(this, "ReshapeAnimations", 16, 400);

}

建立手勢

可喜可賀,在新發布的.NET 8 中, .NET MAUI 引入了指標手勢識別器(PointerGestureRecognizer),使用方式如下,終於不用自己實現手勢監聽控制元件了。

Xaml:

<Image Source="dotnet_bot.png">
    <Image.GestureRecognizers>
        <PointerGestureRecognizer PointerEntered="OnPointerEntered"
                                  PointerExited="OnPointerExited"
                                  PointerMoved="OnPointerMoved" />
  </Image.GestureRecognizers>
</Image>

C#:

void OnPointerEntered(object sender, PointerEventArgs e)
{
    // Handle the pointer entered event
}

void OnPointerExited(object sender, PointerEventArgs e)
{
    // Handle the pointer exited event
}

void OnPointerMoved(object sender, PointerEventArgs e)
{
    // Handle the pointer moved event
}

具體請閱讀官方文件:
.NET 8 中 .NET MAUI 的新增功能以及
識別指標手勢

在本專案中,需要監聽長按動作,當“三連按鈕”長按2秒後,轉為三連模式,此時需要監聽手指釋放情況,當時長不足時取消三連。

由於在之前的文章中實現過監聽手勢,這裡僅簡單介紹定義,其餘內容不再重複,如需瞭解請閱讀: [MAUI程式設計] 用Handler實現自定義跨平臺控制元件

定義可以監聽的手勢類別,分別是按下、移動、抬起、取消、進入、退出


 public enum TouchActionType
    {
        Entered,
        Pressed,
        Moved,
        Released,
        Exited,
        Cancelled
    }

新增手勢監聽器TouchRecognizer,它將提供一個事件OnTouchActionInvoked,用觸發手勢動作。

public partial class TouchRecognizer: IDisposable
{
    public event EventHandler<TouchActionEventArgs> OnTouchActionInvoked;
    public partial void Dispose();
}

EventArg類TouchActionEventArgs,用於傳遞手勢動作的引數

建立互動與動效

在頁面建立完三個按鈕後,在CodeBehind中編寫互動邏輯

新增更新圓環進度的方法UpdateProgressWithAnimate,此方法根據圓環進度的百分比,計算圓環進度的動畫時間,並開始動畫


private void UpdateProgressWithAnimate(CircleProgressBar progressElement, double progressTarget = 100, double totalLenght = 5*1000, Action<double, bool> finished = null)
{
    Content.AbortAnimation("ReshapeAnimations");
    var scaleAnimation = new Animation();

    double progressOrigin = progressElement.Progress;

    var animateAction = (double r) =>
    {
        progressElement.Progress = r;
    };

    var scaleUpAnimation0 = new Animation(animateAction, progressOrigin, progressTarget);

    scaleAnimation.Add(0, 1, scaleUpAnimation0);
    var calcLenght = (double)(Math.Abs((progressOrigin - progressTarget) / 100)) * totalLenght;

    scaleAnimation.Commit(progressElement, "ReshapeAnimations", 16, (uint)calcLenght, finished: finished);

}

建立“三連成功”的動畫方法StartCelebrationAnimate,在這裡透過改變按鈕的Scale和Fill屬性,實現三連成功時“點亮”圖示的動畫效果。


private void StartCelebrationAnimate(Path progressElement, Action<double, bool> finished = null)
{
    var toColor = (Color)this.Resources["BrandColor"];
    var fromColor = (Color)this.Resources["DisabledColor"];

    var scaleAnimation = new Animation();

    var scaleUpAnimation0 = new Animation(v => progressElement.Scale=v, 0, 1.5);
    var scaleUpAnimation1 = new Animation(v => progressElement.Fill=GetColor(v, fromColor, toColor), 0, 1);
    var scaleUpAnimation2 = new Animation(v => progressElement.Scale=v, 1.5, 1);

    scaleAnimation.Add(0, 0.5, scaleUpAnimation0);
    scaleAnimation.Add(0, 1, scaleUpAnimation1);
    scaleAnimation.Add(0.5, 1, scaleUpAnimation2);

    scaleAnimation.Commit(progressElement, "CelebrationAnimate", 16, 400, finished: finished);

}

按鈕觸發TouchContentView_OnTouchActionInvoked事件:

_dispatcherTimer作用是延時1秒,如果按鈕被點選,則開始執行後續操作。當按鈕被點選時此Timer會開。

一秒後,開始進入“三連模式”,此時更新圓環進度,當進度完成後開始氣泡動畫和“三連成功”的動畫。

若中途按鈕被釋放,則取消此Timer,並重置圓環進度。

若按鈕未進入“三連模式”,則直接播放按鈕的點選動畫。

private void TouchContentView_OnTouchActionInvoked(object sender, TouchActionEventArgs e)
{
    var layout = ((Microsoft.Maui.Controls.Layout)(sender as TouchContentView).Content).Children;
    var bubbles = layout[0] as Bubbles;
    var circleProgressBar = layout[1] as CircleProgressBar;


    switch (e.Type)
    {
        case TouchActionType.Entered:
            break;
        case TouchActionType.Pressed:
            _dispatcherTimer =Dispatcher.CreateTimer();
            _dispatcherTimer.Interval=new TimeSpan(0, 0, 1);

            _dispatcherTimer.Tick+=   async (o, e) =>
            {
                _isInProgress=true;
                this.UpdateProgressWithAnimate(ProgressBar1, 100, 2*1000, (d, b) =>
                {
                    if (circleProgressBar.Progress==100)
                    {
                        this.bubbles1.StartAnimation();
                        StartCelebrationAnimate(this.Icon1);
                    }
                });
                this.UpdateProgressWithAnimate(ProgressBar2, 100, 2*1000, (d, b) =>
                {
                    if (circleProgressBar.Progress==100)
                    {
                        this.bubbles2.StartAnimation();
                        StartCelebrationAnimate(this.Icon2);
                    }
                });
                this.UpdateProgressWithAnimate(ProgressBar3, 100, 2*1000, (d, b) =>
                {
                    if (circleProgressBar.Progress==100)
                    {
                        this.bubbles3.StartAnimation();
                        StartCelebrationAnimate(this.Icon3);
                    }
                });

            };

            _dispatcherTimer.Start();


            break;
        case TouchActionType.Moved:
            break;
        case TouchActionType.Released:

            if (!_isInProgress)
            {
                var brandColor = (Color)this.Resources["BrandColor"];
                var disabledColor = (Color)this.Resources["DisabledColor"];

                if (circleProgressBar.Progress==100)
                {
                    this.UpdateProgressWithAnimate(ProgressBar1, 0, 1000);
                    this.UpdateProgressWithAnimate(ProgressBar2, 0, 1000);
                    this.UpdateProgressWithAnimate(ProgressBar3, 0, 1000);
                    (ProgressBar1.LabelContent as Path).Fill=disabledColor;
                    (ProgressBar2.LabelContent as Path).Fill=disabledColor;
                    (ProgressBar3.LabelContent as Path).Fill=disabledColor;


                }
                else
                {
                    if (((circleProgressBar.LabelContent as Path).Fill  as SolidColorBrush).Color==disabledColor)
                    {
                        StartCelebrationAnimate(circleProgressBar.LabelContent as Path);

                    }
                    else
                    {
                        (circleProgressBar.LabelContent as Path).Fill=disabledColor;
                    }
                }


            }
            if (_dispatcherTimer!=null)
            {
                if (_dispatcherTimer.IsRunning)
                {
                    _dispatcherTimer.Stop();
                    _isInProgress=false;

                }
                _dispatcherTimer=null;

                if (circleProgressBar.Progress==100)
                {
                    return;
                }

                this.UpdateProgressWithAnimate(ProgressBar1, 0, 1000);
                this.UpdateProgressWithAnimate(ProgressBar2, 0, 1000);
                this.UpdateProgressWithAnimate(ProgressBar3, 0, 1000);
            }



            break;
        case TouchActionType.Exited:
            break;
        case TouchActionType.Cancelled:
            break;
        default:
            break;
    }
}

進入三聯模式動畫效果如下:
在這裡插入圖片描述

未進入三聯模式動畫效果如下:

在這裡插入圖片描述

最終效果如下:

在這裡插入圖片描述

專案地址

Github:maui-samples

相關文章