[MAUI]模仿iOS多工切換卡片滑動的互動實現

林曉lx發表於2023-05-02

@


看了上一篇博文的評論,大家對MAUI還是比較感興趣的,非常感謝大家的關注,這個專欄我爭取周更?。

App之間的多工切換相信你們都很熟悉。蘋果裝置從iOS9開始使用水平排列的疊層卡片來展現多工

在這裡插入圖片描述
動圖來自iPhone 使用手冊 - 在 iPhone 上的應用之間切換

這個設計利用螢幕深度(z方向)和水平空間(x軸方向)的平順結合,在有限的螢幕空間內,展現了更多的卡片,滑動螢幕時,每一個卡片在螢幕中央的時候也能得到大面積的展示。

今天我們在.NET MAUI中實現這個優秀互動效果
,最終效果如下:

在這裡插入圖片描述

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

原理

使用過的App將以螢幕截圖的卡片方式展現,卡片從右到左依次排列,最近使用的app卡片將靠前,併疊層在其他久未使用的app卡片之上。

平鋪分佈

平鋪分佈是經典的卡片佈局,它的卡片分部是均勻的

在這裡插入圖片描述

在有限的螢幕寬度內呈現6張卡片,疊層放置後每張卡片可顯示部分的寬度為螢幕寬度的1/6

卡片在螢幕橫軸的位置與其偏移量是一個線性關係,如下圖:

在這裡插入圖片描述

iOS多工卡片分佈

在iOS多工卡片的佈局中,卡片在螢幕範圍內的佈局由左向右的密度依次降低:

在這裡插入圖片描述

它的佈局位置是由4段二階貝塞爾曲線拼接成的完整曲線函式計算而來的。

二階貝塞爾曲線,可以透過三個點,來確定一條平滑的曲線。詳情請參考這裡

卡片在螢幕橫軸的位置與其偏移量如下圖:

在這裡插入圖片描述

同樣是在頁面上從左至右呈現6張卡片。利用貝塞爾曲線函式的特性,編號靠前的卡片(1,2,3)的偏移量“滯後”,編號靠後的卡片(4,5,6)的偏移量“追趕”,這樣保證了編號靠後的卡片(較新的App任務)佈局密度降低,從而有更大面積的展示。

在這裡插入圖片描述

計算每一個卡片的偏移量,卡片的大小隨偏移量成正比,效果如下圖:

在這裡插入圖片描述

接下來我們用幾張App截圖代替顏色交替的卡片並賦予其動效。

建立佈局

新建.NET MAUI專案,命名MultitaskingCardList。將介面圖片資原始檔複製到專案\Resources\Images中並將他們包含在MauiImage資源清單中。

<MauiImage Include="Resources\Images\*" />

在MainPage.xaml中,建立一個橫向StackLayout作為App後臺任務卡片容器,我們將使用繫結集合的方式,將App後臺任務新增到這個容器中。

程式碼如下:

<StackLayout Orientation="Horizontal"
    BindingContextChanged="BoxLayout_BindingContextChanged"
    x:Name="BoxLayout"
    BindableLayout.ItemsSource="{Binding AppTombStones}">

它的DataTemplate代表一個App後臺任務,使用Grid佈局,App的截圖與名稱分別位於Grid的第二行和第一行。

<BindableLayout.ItemTemplate>
    <DataTemplate>
        <Grid Style="{StaticResource BoxFrameStyle}" >
            <Grid.RowDefinitions>
                <RowDefinition Height="auto"></RowDefinition>
                <RowDefinition></RowDefinition>
            </Grid.RowDefinitions>
            <Label Margin="25,0,0,0" TranslationY="30"  Text="{Binding AppName}" VerticalOptions="End"></Label>
            <Image  Aspect="AspectFill"
                    Grid.Row="1"
                    HeightRequest="550"
                    WidthRequest="250"
                    Source="{Binding AppScreen}">           
            </Image>

        </Grid>
    </DataTemplate>
</BindableLayout.ItemTemplate>

對卡片Grid的樣式進行定義:

寬度300,高度550,左邊距-220,這使得螢幕區域範圍內有大概5-6個卡片可見。

<ContentPage.Resources>
    <Style TargetType="Grid"
            x:Key="BoxFrameStyle">

        <Setter Property="WidthRequest"
                Value="300"></Setter>
        <Setter Property="Margin"
                Value="0,0,-220,0"></Setter>
        <Setter Property="AnchorX"
                Value="0"></Setter>
    </Style>
</ContentPage.Resources>

效果如下:

在這裡插入圖片描述

建立分佈函式

為了快速對映位置與偏移量,我們在頁面載入時計算出貝塞爾函式曲線上的離散點

二階貝塞爾曲線由三個點確定,分別是:
起始點、終止點(也稱錨點)、控制點

BezierSegments物件將描述4段連續的,首尾相連的二階貝塞爾曲線

在MainPage.xaml.cs中訂閱頁面載入完畢事件PageLoaded,在事件方法中編寫程式碼如下:

var p0 = new Point(0, 1);
var p1 = new Point(0.1, 0.9988);
var p2 = new Point(0.175, 0.9955);


var p3 = new Point(0.4, 0.99);
var p4 = new Point(0.575, 0.92);
var p5 = new Point(0.7, 0.88);

var p6 = new Point(0.775, 0.71);
var p7 = new Point(0.9, 0.4);
var p8 = new Point(1, 0);

this.BezierSegments = new Point[][] {

    new Point[]{p0,p1,p2},
    new Point[]{p2,p3,p4},
    new Point[]{p4,p5,p6},
    new Point[]{p6,p7,p8}
};

bezeirPointSubdivs,標示貝塞爾曲線上點的數量,值越大,曲線越平滑,但計算量也越大,這裡取999

var bezeirPointSubdivs = 999;

根據二階貝塞爾函式式:

在這裡插入圖片描述

將點座標帶入表示式,則可以得出輸入輸出值之間的對映關係,程式碼如下:

X軸座標

var bezeirPointX = Math.Pow(1 - (double)j / bezeirPointSubdivs, 2) * BezierSegments[i][0].X + 2 * (double)j / bezeirPointSubdivs * (1 - (double)j / bezeirPointSubdivs) * BezierSegments[i][1].X + Math.Pow((double)j / bezeirPointSubdivs, 2) * BezierSegments[i][2].X;

Y軸座標:

var bezeirPointY = Math.Pow(1 - (double)j / bezeirPointSubdivs, 2) * BezierSegments[i][0].Y + 2 * (double)j / bezeirPointSubdivs * (1 - (double)j / bezeirPointSubdivs) * BezierSegments[i][1].Y + Math.Pow((double)j / bezeirPointSubdivs, 2) * BezierSegments[i][2].Y;

對每一段的貝塞爾曲線計算,擬合出一條完整曲線
計算而得的離散點存入BezeirPoints,程式碼如下:

for (int i = 0; i < this.BezierSegments.Length; i++)
    {
        for (int j = 0; j < bezeirPointSubdivs; j++)
        {
            var bezeirPointX = Math.Pow(1 - (double)j / bezeirPointSubdivs, 2) * BezierSegments[i][0].X + 2 * (double)j / bezeirPointSubdivs * (1 - (double)j / bezeirPointSubdivs) * BezierSegments[i][1].X + Math.Pow((double)j / bezeirPointSubdivs, 2) * BezierSegments[i][2].X;
            var bezeirPointY = Math.Pow(1 - (double)j / bezeirPointSubdivs, 2) * BezierSegments[i][0].Y + 2 * (double)j / bezeirPointSubdivs * (1 - (double)j / bezeirPointSubdivs) * BezierSegments[i][1].Y + Math.Pow((double)j / bezeirPointSubdivs, 2) * BezierSegments[i][2].Y;
            BezeirPoints.Add(new Point(bezeirPointX, bezeirPointY));

        }
    }

我們使用線性插值法(linear interpolation),計算平移手勢進度,卡片的分佈偏移量以及大小等值。

線性插值法是指使用連線兩個已知量的直線來確定在這兩個已知量之間的一個未知量的值的方法。具體請參考這裡

在這裡插入圖片描述

假設我們已知座標(x0,y0)與(x1,y1),要得到[x0,x1]區間內某一位置x在直線上的值。根據圖中所示,我們得到兩點式直線方程

在這裡插入圖片描述

建立調製方法Modulate,程式碼如下

public double Modulate(double value, double[] source, double[] target)
{
    if (source.Length != 2 || target.Length != 2)
    {
        throw new ArgumentOutOfRangeException();
    }

    var start = source[0];
    var end = source[1];
    var targetStart = target[0];
    var targetEnd = target[1];
    if (value < start || value > end)
    {
        return value;
    }
    var k = (value - start) / (end - start);
    var result = k * (targetEnd - targetStart) + targetStart;
    return result;
}

建立動效

我們將為App後臺任務容器建立平移手勢,實現各個卡片的滾動動效,當使用者指尖在螢幕水平方向上滑動時,卡片內容也應該隨之橫向滾動。

原本的實現方式是控制元件自監聽平移(Pan)事件,透過x軸方向的平移偏移量,計算卡片容器中各個卡片的偏移量,從而實現卡片滾動動效。但平移過後的慣性滑動要自行計算,滑動手感不夠流暢,最終效果並不理想,因此改用MAUI的ScrollView控制元件作為滾動框架

因此滾動行為(滾動阻尼,滾動慣性等)由各平臺的原生程式碼實現。

<ScrollView x:Name="MainScroller"
    Background="Transparent"
    Orientation="Horizontal"
    Scrolled="ScrollView_Scrolled">

    <!--App後臺任務卡片容器-->
    <StackLayout>...</StackLayout>


</ScrollView> 

效果如下:

在這裡插入圖片描述

建立RenderTransform方法,實現卡片的平移,縮放,透明度等動效。
relativeOffsetX為卡片去除了滾動的影響,相對於螢幕的X方向位置。即相位置

透過遍歷BoxLayout中的各卡片相對位置計算進度值progress

再透過調製方法Modulate,計算卡片的縮放,透明度,偏移量等值。

private void RenderTransform(double scrollX)
{
    var layoutWidth = this.MainLayout.DesiredSize.Width;
    if (this.BezeirPoints == null)
    {
        return;
    }
    foreach (var item in this.BoxLayout.Children)
    {
        if (item is VisualElement)
        {
            var relativeOffsetX = (item as VisualElement).X-scrollX;
            var progress = this.Modulate(relativeOffsetX, new double[] { 0, layoutWidth }, new double[] { 0, 1 });
            (item as VisualElement).ScaleTo(Modulate(progress, new double[] { 0, 1 }, new double[] { 0.72, 0.84 }), 0);
            (item as VisualElement).FadeTo(Modulate(progress, new double[] { 0.2, 0.54 }, new double[] { 0, 1 }), 0);
            var modulatedX = Modulate(1 - GetMappingY(progress), new double[] { 0, 1 }, new double[] { 0, layoutWidth });
            var offsetX = modulatedX - relativeOffsetX;
            (item as VisualElement).TranslateTo(offsetX, 0, 0);
        }
    }
}

靜態效果如下:

在這裡插入圖片描述

RenderTransform方法的形參scrollX為滾動框架的滾動偏移量,即MainScroller.ScrollX。

訂閱滾動事件Scrolled,在事件方法中呼叫RenderTransform。程式碼如下:

private void ScrollView_Scrolled(object sender, ScrolledEventArgs e)
{
    RenderTransform(e.ScrollX);
}

建立繫結資料

建立MainPageViewModel.cs,用於介面繫結資料來源。

AppTombStone描述App進入後臺時的狀態(墓碑機制)

public class AppTombStone
{
    public AppTombStone() { }

    public string AppName { get; set; }
    public string AppScreen { get; set; }
    public double TestOffset { get; set; }
}

在MainPageViewModel建構函式中,初始化AppTombStone列表,程式碼如下:

public class MainPageViewModel : INotifyPropertyChanged
{
    public MainPageViewModel()
    {
        var list = new List<AppTombStone>
        {
            new AppTombStone() { AppName="Edge", AppScreen= "p1.png",TestOffset=0},
            new AppTombStone() { AppName="Map", AppScreen= "p2.png",TestOffset=-10 },
            new AppTombStone() { AppName="Photo", AppScreen= "p3.png",TestOffset=-70 },
            new AppTombStone() { AppName="App Store", AppScreen= "p4.png" ,TestOffset=-90},
            new AppTombStone() { AppName="Calculator", AppScreen= "p5.png",TestOffset=-70 },
            new AppTombStone() { AppName="Music", AppScreen= "p6.png" ,TestOffset=-30},
            new AppTombStone() { AppName="File", AppScreen= "p7.png" },
            new AppTombStone() { AppName="Note", AppScreen= "p8.png" },
            new AppTombStone() { AppName="Paint", AppScreen= "p9.png" },
            new AppTombStone() { AppName="Weather", AppScreen= "p10.png" },
            new AppTombStone() { AppName="Chrome", AppScreen= "p11.png" },
            new AppTombStone() { AppName="Book", AppScreen= "p12.png" },
            new AppTombStone() { AppName="Browser", AppScreen= "p13.png" }
        };

        AppTombStones = new ObservableCollection<AppTombStone>(list);
    }

細節調整

首張卡片的處理

這裡遇到個問題,當滾動框架滾動到最左側時,最下方的卡片會被疊層上方的卡片覆蓋,如下圖所示:

在這裡插入圖片描述

當滾動框架滾動到最左側時,我們希望首張卡片不被上方的卡片覆蓋,那麼它至少應當滾動到螢幕的中部,因此需要加一個虛擬的BoxView將首張卡前的空間“撐起來”。

在這裡插入圖片描述

訂閱BoxView的BindingContextChanged事件,在事件方法中新增如下程式碼

private void BoxLayout_BindingContextChanged(object sender, EventArgs e)
    {
        this.BoxLayout.Children.Insert(0, new BoxView()
        {
            WidthRequest=300,
            HeightRequest=500,
            BackgroundColor=Colors.Red
        });
    }

效果:

在這裡插入圖片描述

為卡片新增裁剪

使用Image.Clip和Image.Shadow屬性,為卡片新增圓角裁剪和陰影效果。

<Image  Aspect="AspectFill"
        Grid.Row="1"
        HeightRequest="550"
        WidthRequest="250"
        Source="{Binding AppScreen}">
    <Image.Clip>
        <RoundRectangleGeometry
            CornerRadius="20"
            Rect="0,20,250,480">
        </RoundRectangleGeometry>
    </Image.Clip>
    <Image.Shadow>
        <Shadow Brush="Black"
                Radius="40"
                Offset="-20,0"
                Opacity="0.3" />
    </Image.Shadow>
</Image>

跳轉到最後一張卡片

App後臺任務是從右到左排列的,因此在App啟動時,需要將滾動框架滾動到最後一張卡片,程式碼如下:

private async void ContentPage_SizeChanged(object sender, EventArgs e)
{
    var layoutWidth = this.MainLayout.DesiredSize.Width;

    var scrollY = this.MainScroller.ScrollY;
    var posX = this.MainScroller.ContentSize.Width-layoutWidth;
    await this.MainScroller.ScrollToAsync(posX, scrollY, false).ContinueWith((t) =>
    {
        RenderTransform(this.MainScroller.ScrollX);
    });

}

最終效果:

在這裡插入圖片描述

專案地址

Github:maui-samples

相關文章