[MAUI]模仿Chrome下拉分頁的互動實現

2023-05-28 21:02:12

@


今天來說說怎樣在.NET MAUI 中製作一個靈動的類分頁控制元件,這類控制元件常用於頁面中多個子頁面的導航功能。

比如在手機版的Chrome中,當用戶在網頁中下拉時將出現「新建分頁」,「重新整理」,「關閉分頁」三個選項,通過不間斷的橫向手勢滑動,可以在這三個選項之間切換。選項指示器是一個帶有粘滯效果的圓,如下圖:


圖 - iOS版Edge瀏覽器下拉重新整理功能

瀏覽網頁常用選項融入到了原「下拉重新整理」互動中,對比傳統互動方式它更顯便捷和流暢,根據Steve Krug之《Don't Make Me Think》的核心思想,使用者無需思考點選次序,只需要使用基礎動作就能完成互動。

今天在.NET MAUI 中實現Chrome下拉分頁互動,以及常見的新聞類App中的分頁切換互動
,最終效果如下:


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

建立粘滯效果的圓控制元件

粘滯效果模仿了水滴,或者「史萊姆」等等這種粘性物質受外力作用的形變效果。

要實現此效果,首先請出我們的老朋友——貝塞爾曲線,二階貝塞爾曲線可以根據三點:起始點、終止點(也稱錨點)、控制點繪製出一條平滑的曲線,利用多段貝塞爾曲線函數,可以擬合出一個圓。

通過微調各曲線的控制點,可以使圓產生形變效果,即模仿了粘滯效果。

貝塞爾曲線繪製圓

用貝塞爾曲線無法完美繪製出圓,只能無限接近圓。

對於n的貝塞爾曲線,到曲線控制點的最佳距離是(4/3)*tan(pi/(2n)),詳細推導過程可以檢視這篇文章https://spencermortensen.com/articles/bezier-circle/

因此,對於4分,它是(4/3)tan(pi/8) = 4(sqrt(2)-1)/3 = 0.552284749831。

建立控制元件

我們建立控制元件StickyPan,在Xaml部分,我們建立一個包含四段BezierSegment的Path,程式碼如下:

<?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"
             SizeChanged="ContentView_SizeChanged"
             Background="white"            
             x:Class="StickyTab.Controls.StickyPan">
    <Grid>
        <Path x:Name="MainPath">
            <Path.Data>
                <PathGeometry>
                    <PathFigure x:Name="figure1" Stroke="red">
                        <PathFigure.Segments>
                            <PathSegmentCollection>
                                <BezierSegment x:Name="arc1" />
                                <BezierSegment x:Name="arc2" />
                                <BezierSegment x:Name="arc3" />
                                <BezierSegment x:Name="arc4" />
                            </PathSegmentCollection>
                        </PathFigure.Segments>
                    </PathFigure>
                </PathGeometry>
            </Path.Data>
     
        </Path>
    </Grid>
</ContentView>

我們對4段貝塞爾曲線的各起始點、終止點以及控制點定義如下

請記住這些點的名稱,在給圓新增形變時會參照這些點。

圓的大小為控制元件的寬高,圓心為控制元件的中心點。根據公式,我們計算出控制點的偏移量

private double C = 0.552284749831f;

public double RadiusX => this.Width/2;
public double RadiusY => this.Height/2;
public Point Center => new Point(this.Width/2, this.Height/2);

public double DifferenceX => RadiusX * C;
public double DifferenceY => RadiusY * C;


根據控制點偏移量計算出各控制點的座標
以及貝塞爾曲線的起始點和終止點:

Point p0 = new Point(Width/2, 0);
Point h1 = new Point(Width/2-DifferenceX, 0);
Point h2 = new Point(this.Width/2+DifferenceX, 0);
Point h3 = new Point(this.Width, this.Height/2-  DifferenceY);
Point p1 = new Point(this.Width, this.Height/2);
Point h4 = new Point(this.Width, this.Height/2+DifferenceY);
Point h5 = new Point(this.Width/2+DifferenceX, this.Height);
Point p2 = new Point(this.Width/2, this.Height);
Point h6 = new Point(this.Width/2-DifferenceX, this.Height);
Point h7 = new Point(0, this.Height/2+DifferenceY);
Point p3 = new Point(0, this.Height/2);
Point h8 = new Point(0, this.Height/2-DifferenceY);

如此,我們便繪製了一個圓

this.figure1.StartPoint =  p0;

this.arc1.Point1 = h2;
this.arc1.Point2 = h3;
this.arc1.Point3 = p1;


this.arc2.Point1 = h4;
this.arc2.Point2 = h5;
this.arc2.Point3 = p2;

this.arc3.Point1 = h6;
this.arc3.Point2 = h7;
this.arc3.Point3 = p3;

this.arc4.Point1 = h8;
this.arc4.Point2 = h1;

this.arc4.Point3 = p0;

效果如下:

建立形變

現在想象這個圓是一顆水珠,假設我們要改變圓的形狀,形成向右的「水滴狀」。

水的體積是不會變的,當一邊發生擴張形變,相鄰的兩邊必定收縮形變。

假設x方向的形變數為dy,y方向的形變數為dx,收縮形變係數為0.4,擴張形變係數為0.8,應用到p0、p1、p2、p3的點座標變化如下:


var dx = 400*0.8;
var dy = 400*0.4;
p0= p0.Offset(0, Math.Abs(dy));
p1= p1.Offset(dx, 0);
p2 = p2.Offset(0, -Math.Abs(dy));

p0變換後的座標為p0',p1變換後的座標為p1',p2變換後的座標為p2'。
變換前後的對比如下:

可控形變

請注意,上一小節提到的形變數dx、dy是固定的,我們需要將形變數變為可變,這樣才能實現水滴的形變。

我們定義兩個變數_offsetX、_offsetY,用於控制形變數的大小。計算形變數的正負值確定形變的方向。不同方向上平移作用的點不同,計算出各點的座標變化如下:

var dx = _offsetX * 0.8 + _offsetY * 0.4;
var dy = _offsetX * 0.4 + _offsetY * 0.8;
if (_offsetX != 0)
{
    if (dx > 0)
    {
        p1 = p1.Offset(dx, 0);

    }
    else
    {
        p3 = p3.Offset(dx, 0);
    }
    p0 = p0.Offset(0, Math.Abs(dy));
    p2 = p2.Offset(0, -Math.Abs(dy));
}

if (_offsetY != 0)
{
    if (dy > 0)
    {
        p2 = p2.Offset(0, dy);
    }

    else
    {
        p0 = p0.Offset(0, dy);
    }
    p1 = p1.Offset(-Math.Abs(dx), 0);
    p3 = p3.Offset(Math.Abs(dx), 0);

}

這樣在x,y方向可以產生自由形變

注意此時我們引入了PanWidth、PanHeight兩個屬性描述圓的尺寸,因為圓會發生擴張形變,圓的邊緣不應該再為控制元件邊緣

public double RadiusX => this.PanWidth / 2;
public double RadiusY => this.PanHeight / 2;


//圓形居中補償
var adjustX = (this.Width - PanWidth) / 2 ;
var adjustY = (this.Height - PanHeight) / 2 ;

Point p0 = new Point(PanWidth / 2 + adjustX, adjustY);
Point p1 = new Point(this.PanWidth + adjustX, this.PanHeight / 2 + adjustY);
Point p2 = new Point(this.PanWidth / 2 + adjustX, this.PanHeight + adjustY);
Point p3 = new Point(adjustX, this.PanHeight / 2 + adjustY);

形變邊界

首先確定一個「容忍度」,當形變數超過容忍度時,不再產生形變,這樣可以避免形變過大,導致圓形形變過渡。

這個容忍度將由控制元件到目標點的距離決定,可以想象這個粘稠的水滴在粘連時,距離越遠,粘連越弱。當距離超過容忍度時,粘連就會斷開。

此時offsetX、offsetY正好可以代表這個距離,我們可以通過offsetX、offsetY計算出距離,然後與容忍度比較,超過容忍度則將不黏連。

var _offsetX = OffsetX;
//超過容忍度則將不黏連
if (OffsetX <= -(this.Width - PanWidth) / 2 || OffsetX > (this.Width - PanWidth) / 2)
{
    _offsetX = 0;
}

var _offsetY = OffsetY;
//超過容忍度則將不黏連
if (OffsetY <= -(this.Height - PanHeight) / 2 || OffsetY > (this.Height - PanHeight) / 2)
{
    _offsetY = 0;
}

容忍度不應超過圓邊界到控制元件邊界的距離,此處為±50;

因為是黏連,所以在容忍度範圍內,要模擬粘連的效果,圓發生形變時,實際上是力作用於圓上的點,所以是圓上的點發生位移,而不是圓本身。

將offsetX和offsetY考慮進補償偏移量計算,重新計算貝塞爾曲線各點的座標

var adjustX = (this.Width - PanWidth) / 2 - _offsetX;
var adjustY = (this.Height - PanHeight) / 2 - _offsetY;

Point p0 = new Point(PanWidth / 2 + adjustX, adjustY);
Point p1 = new Point(this.PanWidth + adjustX, this.PanHeight / 2 + adjustY);
Point p2 = new Point(this.PanWidth / 2 + adjustX, this.PanHeight + adjustY);
Point p3 = new Point(adjustX, this.PanHeight / 2 + adjustY);

當改變控制元件和目標距離時,圓有了一種「不想離開」的感覺,此時模擬了圓的粘滯效果。

形變動畫

當圓的形變超過容忍度時,圓會恢復到原始狀態,此時需要一個動畫,模擬回彈效果。

我們不必計算動畫路徑細節,只需要計算動畫的起始點和終止點:

  • 重新計算原始狀態的貝塞爾曲線各點的位置作為終止點

  • 貝塞爾曲線各點的當前位置,作為起始點

建立方法Animate,程式碼如下:

private void Animate(Action<double, bool> finished = null)
{
    Content.AbortAnimation("ReshapeAnimations");
    var scaleAnimation = new Animation();


    var adjustX = (this.Width - PanWidth) / 2;
    var adjustY = (this.Height - PanHeight) / 2;

    Point p0Target = new Point(PanWidth / 2 + adjustX, adjustY);
    Point p1Target = new Point(this.PanWidth + adjustX, this.PanHeight / 2 + adjustY);
    Point p2Target = new Point(this.PanWidth / 2 + adjustX, this.PanHeight + adjustY);
    Point p3Target = new Point(adjustX, this.PanHeight / 2 + adjustY);

    Point p0Origin = this.figure1.StartPoint;
    Point p1Origin = this.arc1.Point3;
    Point p2Origin = this.arc2.Point3;
    Point p3Origin = this.arc3.Point3;

    ...
}

使用線性插值法,根據進度值r,計算各點座標。線性插值法在之前的文章有介紹,或參考這裡,此篇將不贅述。

var animateAction = (double r) =>
{

    Point p0 = new Point((p0Target.X - p0Origin.X) * r + p0Origin.X, (p0Target.Y - p0Origin.Y) * r + p0Origin.Y);
    Point p1 = new Point((p1Target.X - p1Origin.X) * r + p1Origin.X, (p1Target.Y - p1Origin.Y) * r + p1Origin.Y);
    Point p2 = new Point((p2Target.X - p2Origin.X) * r + p2Origin.X, (p2Target.Y - p2Origin.Y) * r + p2Origin.Y);
    Point p3 = new Point((p3Target.X - p3Origin.X) * r + p3Origin.X, (p3Target.Y - p3Origin.Y) * r + p3Origin.Y);

    Point h1 = new Point(p0.X - DifferenceX, p0.Y);
    Point h2 = new Point(p0.X + DifferenceX, p0.Y);
    Point h3 = new Point(p1.X, p1.Y - DifferenceY);
    Point h4 = new Point(p1.X, p1.Y + DifferenceY);
    Point h5 = new Point(p2.X + DifferenceX, p2.Y);
    Point h6 = new Point(p2.X - DifferenceX, p2.Y);
    Point h7 = new Point(p3.X, p3.Y + DifferenceY);
    Point h8 = new Point(p3.X, p3.Y - DifferenceY);


    this.figure1.StartPoint = p0;
    this.arc1.Point1 = h2;
    this.arc1.Point2 = h3;
    this.arc1.Point3 = p1;


    this.arc2.Point1 = h4;
    this.arc2.Point2 = h5;
    this.arc2.Point3 = p2;

    this.arc3.Point1 = h6;
    this.arc3.Point2 = h7;
    this.arc3.Point3 = p3;

    this.arc4.Point1 = h8;
    this.arc4.Point2 = h1;

    this.arc4.Point3 = p0;
};

將動畫新增到Animation物件中,然後提交動畫。

動畫觸發,將在400毫秒內完成圓的復原。

var scaleUpAnimation0 = new Animation(animateAction, 0, 1);
scaleAnimation.Add(0, 1, scaleUpAnimation0);
scaleAnimation.Commit(this, "ReshapeAnimations", 16, 400, finished: finished);

效果如下:

可以使用自定義緩動函數調整動畫效果, 在之前的文章介紹了自定義緩動函數,此篇將不贅述。

使用如下影象的函數曲線,可以使動畫新增一個慣性回彈效果。

應用此函數,程式碼如下:

var mySpringOut = (double x) => (x - 1) * (x - 1) * ((5f + 1) * (x - 1) + 5) + 1;
var scaleUpAnimation0 = new Animation(animateAction, 0, 1, mySpringOut);
...

執行效果如下,這使得這個帶有粘性的圓的回彈過程更有質量感

如果你覺得這樣不夠「彈」

可以使用阻尼振盪函數作為動畫自定義緩動函數,此函數擬合的影象如下:

執行效果如下:

建立手勢控制元件

.NET MAUI 跨平臺框架包含了識別平移手勢的功能,在之前的博文[MAUI 專案實戰] 手勢控制音樂播放器(二): 手勢互動中利用此功能實現了pan-pit拖拽系統。此篇將不贅述。

簡單來說就是拖拽物(pan)體到坑(pit)中,手勢容器控制元件PanContainer描述了pan運動和pit位置的關係,並在手勢運動中產生一系列訊息事件。

建立頁面佈局

新建.NET MAUI專案,命名StickyTab

MainPage.xaml中新增如下程式碼:

<ContentPage.Content>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="200" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        <Grid Grid.Row="0"
                BackgroundColor="#F1F1F1">
            <Grid x:Name="PitContentLayout"
                    ZIndex="1">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="1*" />
                    <ColumnDefinition Width="1*" />
                    <ColumnDefinition Width="1*" />
                </Grid.ColumnDefinitions>

                <controls1:PitGrid x:Name="NewTabPit"
                                    PitName="NewTabPit"
                                    WidthRequest="100"
                                    HeightRequest="200"
                                    Grid.Column="0">

                    <Label   x:Name="NewTabLabel"
                                TextColor="Black"
                                FontFamily="FontAwesome"
                                FontSize="28"
                                HorizontalOptions="CenterAndExpand"
                                Margin="0"></Label>
                    <Label  Margin="0,100,0,0"
                            Opacity="0"
                                Text="新建分頁"
                                TextColor="#6E6E6E"
                                FontSize="18"
                                HorizontalOptions="CenterAndExpand"
                            ></Label>


                </controls1:PitGrid>
                <controls1:PitGrid x:Name="RefreshPit"
                                    PitName="RefreshPit"
                                    WidthRequest="100"
                                    HeightRequest="200"
                                    Grid.Column="1">

                    <Label   x:Name="RefreshLabel"
                                TextColor="Black"
                                FontFamily="FontAwesome"
                                FontSize="28"
                                HorizontalOptions="CenterAndExpand"
                                Margin="0"></Label>
                    <Label  Margin="0,100,0,0"
                            Opacity="0"
                            Text="重新整理"
                            TextColor="#6E6E6E"
                            FontSize="18"
                            HorizontalOptions="CenterAndExpand"></Label>
                </controls1:PitGrid>
                <controls1:PitGrid x:Name="CloseTabPit"
                                    PitName="CloseTabPit"
                                    WidthRequest="100"
                                    HeightRequest="200"
                                    Grid.Column="2">

                    <Label   x:Name="CloseTabLabel"
                                TextColor="Black"
                                FontFamily="FontAwesome"
                                FontSize="28"
                                HorizontalOptions="CenterAndExpand"
                                Margin="0"></Label>
                    <Label  Margin="0,100,0,0"
                            Opacity="0"
                            Text="關閉分頁"
                            TextColor="#6E6E6E"
                            FontSize="18"
                            HorizontalOptions="CenterAndExpand"></Label>
                </controls1:PitGrid>
            </Grid>
            <controls1:PanContainer BackgroundColor="Transparent" ZIndex="0"
                                    x:Name="DefaultPanContainer"
                                    OnTapped="DefaultPanContainer_OnOnTapped"
                                    AutoAdsorption="False"
                                    PanScale="1.0"
                                    SpringBack="True"
                                    PanScaleAnimationLength="100"
                                    Orientation="Horizontal">

                <Grid PropertyChanged="BindableObject_OnPropertyChanged"
                        VerticalOptions="Start"
                        HorizontalOptions="Start">

                    <controls:StickyPan x:Name="MainStickyPan"
                                        Background="Transparent"
                                        PanStrokeBrush="Transparent"
                                        PanFillBrush="White"
                                        AnimationLength="400"
                                        PanHeight="80"
                                        PanWidth="80"
                                        HeightRequest="120"
                                        WidthRequest="120">
                        
                        
                        
                    </controls:StickyPan>

                </Grid>


            </controls1:PanContainer>

        </Grid>
    </Grid>
</ContentPage.Content>

頁面佈局看起來像這樣:

更新拖拽物位置

在Xaml中我們訂閱了PropertyChanged事件,當拖拽物的位置發生變化時,我們需要更新拖拽系統中目標坑的位置。

_currentDefaultPit變數用於記錄當前拖拽物所在的坑,當拖拽物離開坑時,我們需要將其設定為null。

private PitGrid _currentDefaultPit;


private void BindableObject_OnPropertyChanged(object sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName == nameof(Width))
    {
        this.DefaultPanContainer.PositionX = (this.PitContentLayout.Width - (sender as Grid).Width) / 2;
    }
    else if (e.PropertyName == nameof(Height))
    {
        this.DefaultPanContainer.PositionY = (this.PitContentLayout.Height - (sender as Grid).Height) / 2;

    }
    else if (e.PropertyName == nameof(TranslationX))
    {
        var centerX = 0.0;
        if (_currentDefaultPit != null)
        {
            centerX = _currentDefaultPit.X + _currentDefaultPit.Width / 2;
        }
        this.MainStickyPan.OffsetX = this.DefaultPanContainer.Content.TranslationX + this.DefaultPanContainer.Content.Width / 2 - centerX;

    }
}

如下動圖說明了目標坑變化時的效果,當拖拽物離開「重新整理」時,粘滯效果的目標坑轉移到了「新建分頁」上,接近「新建分頁」時產生對它的粘滯效果

其它細節

在拖拽物之於坑的狀態改變時,顯示或隱藏拖拽物本身以及提示文字

private void PanActionHandler(object recipient, PanActionArgs args)
{
    switch (args.PanType)
    {
        case PanType.Out:
            tipLabel = args.CurrentPit?.Children.LastOrDefault() as Label;
            if (tipLabel!=null)
            {
                tipLabel.FadeTo(0);
            }
            break;
        case PanType.In:
            tipLabel = args.CurrentPit?.Children.LastOrDefault() as Label;
            if (tipLabel!=null)
            {
                tipLabel.FadeTo(1);
            }
            break;
        case PanType.Over:
            tipLabel.FadeTo(0);
            ShowLayout(0);
            break;
        case PanType.Start:
            ShowLayout();
            break;
    }
    _currentDefaultPit = args.CurrentPit;

}

private void ShowLayout(double opacity = 1)
{
    var length = opacity==1 ? 250 : 0;
    this.DefaultPanContainer.FadeTo(opacity, (uint)length);
}


最終效果如下:


新聞類標籤互動部分與Chrome下拉分頁互動類似,此篇將不展開講解。
最終效果如下:

專案地址

Github:maui-samples