MAUI模仿iOS多任務(wù)切換卡片滑動的交互實現(xiàn)代碼
上一篇博文的評論,大家對MAUI還是比較感興趣的,非常感謝大家的關(guān)注,這個專欄我爭取周更??。
App之間的多任務(wù)切換相信你們都很熟悉。蘋果設(shè)備從iOS9開始使用水平排列的疊層卡片來展現(xiàn)多任務(wù)
動圖來自iPhone 使用手冊 - 在 iPhone 上的應(yīng)用之間切換
這個設(shè)計利用屏幕深度(z方向)和水平空間(x軸方向)的平順結(jié)合,在有限的屏幕空間內(nèi),展現(xiàn)了更多的卡片,滑動屏幕時,每一個卡片在屏幕中央的時候也能得到大面積的展示。
今天我們在.NET MAUI中實現(xiàn)這個優(yōu)秀交互效果
,最終效果如下:
使用.NET MAU實現(xiàn)跨平臺支持,本項目可運行于Android、iOS平臺。
原理
使用過的App將以屏幕截圖的卡片方式展現(xiàn),卡片從右到左依次排列,最近使用的app卡片將靠前,并疊層在其他久未使用的app卡片之上。
平鋪分布
平鋪分布是經(jīng)典的卡片布局,它的卡片分部是均勻的
在有限的屏幕寬度內(nèi)呈現(xiàn)6張卡片,疊層放置后每張卡片可顯示部分的寬度為屏幕寬度的1/6
卡片在屏幕橫軸的位置與其偏移量是一個線性關(guān)系,如下圖:
iOS多任務(wù)卡片分布
在iOS多任務(wù)卡片的布局中,卡片在屏幕范圍內(nèi)的布局由左向右的密度依次降低:
它的布局位置是由4段二階貝塞爾曲線拼接成的完整曲線函數(shù)計算而來的。
二階貝塞爾曲線,可以通過三個點,來確定一條平滑的曲線。詳情請參考這里
卡片在屏幕橫軸的位置與其偏移量如下圖:
同樣是在頁面上從左至右呈現(xiàn)6張卡片。利用貝塞爾曲線函數(shù)的特性,編號靠前的卡片(1,2,3)的偏移量“滯后”,編號靠后的卡片(4,5,6)的偏移量“追趕”,這樣保證了編號靠后的卡片(較新的App任務(wù))布局密度降低,從而有更大面積的展示。
計算每一個卡片的偏移量,卡片的大小隨偏移量成正比,效果如下圖:
接下來我們用幾張App截圖代替顏色交替的卡片并賦予其動效。
創(chuàng)建布局
新建.NET MAUI項目,命名MultitaskingCardList
。將界面圖片資源文件拷貝到項目\Resources\Images中并將他們包含在MauiImage資源清單中。
<MauiImage Include="Resources\Images\*" />
在MainPage.xaml中,創(chuàng)建一個橫向StackLayout作為App后臺任務(wù)卡片容器,我們將使用綁定集合的方式,將App后臺任務(wù)添加到這個容器中。
代碼如下:
<StackLayout Orientation="Horizontal" BindingContextChanged="BoxLayout_BindingContextChanged" x:Name="BoxLayout" BindableLayout.ItemsSource="{Binding AppTombStones}">
它的DataTemplate代表一個App后臺任務(wù),使用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,這使得屏幕區(qū)域范圍內(nèi)有大概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>
效果如下:
創(chuàng)建分布函數(shù)
為了快速映射位置與偏移量,我們在頁面加載時計算出貝塞爾函數(shù)曲線上的離散點
二階貝塞爾曲線由三個點確定,分別是:
起始點、終止點(也稱錨點)、控制點
BezierSegments對象將描述4段連續(xù)的,首尾相連的二階貝塞爾曲線
在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,標(biāo)示貝塞爾曲線上點的數(shù)量,值越大,曲線越平滑,但計算量也越大,這里取999
var bezeirPointSubdivs = 999;
根據(jù)二階貝塞爾函數(shù)式:
將點坐標(biāo)帶入表達式,則可以得出輸入輸出值之間的映射關(guān)系,代碼如下:
X軸坐標(biāo)
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軸坐標(biāo):
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),計算平移手勢進度,卡片的分布偏移量以及大小等值。
線性插值法是指使用連接兩個已知量的直線來確定在這兩個已知量之間的一個未知量的值的方法。具體請參考這里
假設(shè)我們已知坐標(biāo)(x0,y0)與(x1,y1),要得到[x0,x1]區(qū)間內(nèi)某一位置x在直線上的值。根據(jù)圖中所示,我們得到兩點式直線方程
創(chuàng)建調(diào)制方法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; }
創(chuàng)建動效
我們將為App后臺任務(wù)容器創(chuàng)建平移手勢,實現(xiàn)各個卡片的滾動動效,當(dāng)用戶指尖在屏幕水平方向上滑動時,卡片內(nèi)容也應(yīng)該隨之橫向滾動。
原本的實現(xiàn)方式是控件自監(jiān)聽平移(Pan)事件,通過x軸方向的平移偏移量,計算卡片容器中各個卡片的偏移量,從而實現(xiàn)卡片滾動動效。但平移過后的慣性滑動要自行計算,滑動手感不夠流暢,最終效果并不理想,因此改用MAUI的ScrollView控件作為滾動框架
因此滾動行為(滾動阻尼,滾動慣性等)由各平臺的原生代碼實現(xiàn)。
<ScrollView x:Name="MainScroller" Background="Transparent" Orientation="Horizontal" Scrolled="ScrollView_Scrolled"> <!--App后臺任務(wù)卡片容器--> <StackLayout>...</StackLayout> </ScrollView>
效果如下:
創(chuàng)建RenderTransform方法,實現(xiàn)卡片的平移,縮放,透明度等動效。
relativeOffsetX為卡片去除了滾動的影響,相對于屏幕的X方向位置。即相位置
通過遍歷BoxLayout中的各卡片相對位置計算進度值progress
再通過調(diào)制方法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); } } }
靜態(tài)效果如下:
RenderTransform方法的形參scrollX為滾動框架的滾動偏移量,即MainScroller.ScrollX。
訂閱滾動事件Scrolled,在事件方法中調(diào)用RenderTransform。代碼如下:
private void ScrollView_Scrolled(object sender, ScrolledEventArgs e) { RenderTransform(e.ScrollX); }
創(chuàng)建綁定數(shù)據(jù)
創(chuàng)建MainPageViewModel.cs,用于界面綁定數(shù)據(jù)源。
AppTombStone描述App進入后臺時的狀態(tài)(墓碑機制)
public class AppTombStone { public AppTombStone() { } public string AppName { get; set; } public string AppScreen { get; set; } public double TestOffset { get; set; } }
在MainPageViewModel構(gòu)造函數(shù)中,初始化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); }
細節(jié)調(diào)整
首張卡片的處理
這里遇到個問題,當(dāng)滾動框架滾動到最左側(cè)時,最下方的卡片會被疊層上方的卡片覆蓋,如下圖所示:
當(dāng)滾動框架滾動到最左側(cè)時,我們希望首張卡片不被上方的卡片覆蓋,那么它至少應(yīng)當(dāng)滾動到屏幕的中部,因此需要加一個虛擬的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>
跳轉(zhuǎn)到最后一張卡片
App后臺任務(wù)是從右到左排列的,因此在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); }); }
最終效果:
項目地址
到此這篇關(guān)于[MAUI]模仿iOS多任務(wù)切換卡片滑動的交互實現(xiàn)的文章就介紹到這了,更多相關(guān)ios多任務(wù)切換卡片滑動內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
iOS使用runtime修改文本框(TextField)的占位文字顏色
相信大家都知道TextField默認的占位顏色也是深灰色,這個顏色比較難看清,這篇文章給大家介紹如何使用runtime修改TextField文本框的占位文字顏色,有需要的可以參考借鑒.2016-09-09