- 原理
- 创建布局
- 创建分布函数
- 创建动效
- 创建绑定数据
-
细节调整
- 首张卡片的处理
- 为卡片添加裁剪
- 跳转到最后一张卡片
- 项目地址
看了上一篇博文的评论,大家对MAUI还是比较感兴趣的,非常感谢大家的关注,这个专栏我争取周更😉。
动图来自iPhone 使用手册 - 在 iPhone 上的应用之间切换
今天我们在.NET MAUI中实现这个优秀交互效果
,最终效果如下:
原理
使用过的App将以屏幕截图的卡片方式展现,卡片从右到左依次排列,最近使用的app卡片将靠前,并叠层在其他久未使用的app卡片之上。
平铺分布是经典的卡片布局,它的卡片分部是均匀的
卡片在屏幕横轴的位置与其偏移量是一个线性关系,如下图:
在iOS多任务卡片的布局中,卡片在屏幕范围内的布局由左向右的密度依次降低:
二阶贝塞尔曲线,可以通过三个点,来确定一条平滑的曲线。详情请参考这里
创建布局
新建.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的样式进行定义:
<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>
效果如下:
创建分布函数
二阶贝塞尔曲线由三个点确定,分别是:
起始点、终止点(也称锚点)、控制点
在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 - (doublej / bezeirPointSubdivs, 2 * BezierSegments[i][0].X + 2 * (doublej / bezeirPointSubdivs * (1 - (doublej / bezeirPointSubdivs * BezierSegments[i][1].X + Math.Pow((doublej / bezeirPointSubdivs, 2 * BezierSegments[i][2].X;
Y轴坐标:
var bezeirPointY = Math.Pow(1 - (doublej / bezeirPointSubdivs, 2 * BezierSegments[i][0].Y + 2 * (doublej / bezeirPointSubdivs * (1 - (doublej / bezeirPointSubdivs * BezierSegments[i][1].Y + Math.Pow((doublej / 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 - (doublej / bezeirPointSubdivs, 2 * BezierSegments[i][0].X + 2 * (doublej / bezeirPointSubdivs * (1 - (doublej / bezeirPointSubdivs * BezierSegments[i][1].X + Math.Pow((doublej / bezeirPointSubdivs, 2 * BezierSegments[i][2].X;
var bezeirPointY = Math.Pow(1 - (doublej / bezeirPointSubdivs, 2 * BezierSegments[i][0].Y + 2 * (doublej / bezeirPointSubdivs * (1 - (doublej / bezeirPointSubdivs * BezierSegments[i][1].Y + Math.Pow((doublej / bezeirPointSubdivs, 2 * BezierSegments[i][2].Y;
BezeirPoints.Add(new Point(bezeirPointX, bezeirPointY;
}
}
我们使用线性插值法(linear interpolation,计算平移手势进度,卡片的分布偏移量以及大小等值。
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后台任务容器创建平移手势,实现各个卡片的滚动动效,当用户指尖在屏幕水平方向上滑动时,卡片内容也应该随之横向滚动。
因此滚动行为(滚动阻尼,滚动惯性等)由各平台的原生代码实现。
<ScrollView x:Name="MainScroller"
Background="Transparent"
Orientation="Horizontal"
Scrolled="ScrollView_Scrolled">
<!--App后台任务卡片容器-->
<StackLayout>...</StackLayout>
</ScrollView>
效果如下:
relativeOffsetX为卡片去除了滚动的影响,相对于屏幕的X方向位置。即相位置
再通过调制方法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;
}
}
}
静态效果如下:
订阅滚动事件Scrolled,在事件方法中调用RenderTransform。代码如下:
private void ScrollView_Scrolled(object sender, ScrolledEventArgs e
{
RenderTransform(e.ScrollX;
}
创建绑定数据
创建MainPageViewModel.cs,用于界面绑定数据源。
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;
}
细节调整
首张卡片的处理
这里遇到个问题,当滚动框架滚动到最左侧时,最下方的卡片会被叠层上方的卡片覆盖,如下图所示:
private void BoxLayout_BindingContextChanged(object sender, EventArgs e
{
this.BoxLayout.Children.Insert(0, new BoxView(
{
WidthRequest=300,
HeightRequest=500,
BackgroundColor=Colors.Red
};
}
效果:
为卡片添加裁剪
<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