[MAUI]模仿iOS多任务切换卡片滑动的交互实现

news2024/10/6 6:43:47

文章目录

    • 原理
    • 创建布局
    • 创建分布函数
    • 创建动效
    • 创建绑定数据
    • 细节调整
      • 首张卡片的处理
      • 为卡片添加裁剪
      • 跳转到最后一张卡片
    • 项目地址

看了上一篇博文的评论,大家对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

关注我,学习更多.NET MAUI开发知识!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/483509.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

git把我本地文件传到我的指定的仓库

在使用Git将本地文件推送到指定仓库之前&#xff0c;请确保已经安装了Git并进行了基本配置。接下来&#xff0c;遵循以下步骤将本地文件推送到远程仓库&#xff1a; 兄弟先赏析悦目一下&#xff0c;摸个鱼 首先&#xff0c;在本地文件夹中打开命令行界面&#xff08;在Windows上…

关于I帧/IDR、B帧、P帧、SPS、PPS

在h264编解码中&#xff0c;常常有I帧/IDR/B帧/P帧/IDR/NALU/GOP/&#xff0c;但往往没有关注细节。或者我们本身在实际应用中与使用过很多次&#xff0c;但对相关的技术名词不清楚。 在H264协议里定义了三种帧&#xff0c;完整编码的帧叫I帧&#xff0c;参考之前的I帧生成的只…

【C语言】 知识点汇总--基础知识点梳理(超全超详细)

目录 一、从源代码到exe 二、基本数据类型 三、字符在屏幕上的显示原理 四、溢出现象 五、类型转换规律 六、短路问题 七、指针变量类型的作用 八、指针类型的扩展——多级指针 九、指针类型的扩展——指针数组 十、指针类型的扩展——数组指针 十一、一维数组-名-特…

Doris(24):Doris的函数—聚合函数

1 APPROX_COUNT_DISTINCT(expr) 返回类似于 COUNT(DISTINCT col) 结果的近似值聚合函数。 它比 COUNT 和 DISTINCT 组合的速度更快,并使用固定大小的内存,因此对于高基数的列可以使用更少的内存。 select city,approx_count_distinct(user_id) from site_visit group by c…

Go语言-数据结构与算法

20.4 稀疏 sparsearray 数组 20.4.1 先看一个实际的需求  编写的五子棋程序中&#xff0c;有存盘退出和续上盘的功能 稀疏数组的处理方法是 : 1) 记录数组一共有几行几列&#xff0c;有多少个不同的值 2) 思想&#xff1a;把具有不同值的元素的行列及值记录在一个…

前端三剑客之HTML】

⭐个人主页&#xff1a;书生♡博客主页&#x1f64b;‍♂ &#x1f351;博客领域&#xff1a;java编程,前端&#xff0c;算法&#xff0c;强训题目 写作风格&#xff1a;干货,干货,还是tmd的干货 支持博主&#xff1a;点赞、收藏⭐、留言&#x1f4ac; 目录 1.前端1.1什么是前端…

MySQL学习笔记第七天

第07章单行函数 2. 数值函数 2.4 指数函数、对数函数 函数用法POW(x,y)&#xff0c;POWER(X,Y)返回x的y次方EXP(X)返回e的x次方&#xff0c;其中e是一个常数&#xff0c;2.718281828459045LN(X)&#xff0c;LOG(X)返回以e为底的X的对数&#xff0c;当x<0时&#xff0c;返…

基于FPGA+JESD204B 时钟双通道 6.4GSPS 高速数据采集模块设计(一)总体方案

本章将根据高速数据采集指标要求&#xff0c;分析并确定高速数据采集模块的设计方 案&#xff0c;由此分析数据存储需求及存储速度需求给出高速大容量数据存储方案&#xff0c;完成 双通道高速数据采集模块总体设计方案&#xff0c;并综合采集、存储方案及 AXIe 接口需求 …

第一个C++程序

一、c结构 计算两个数的和&#xff1a; #include <iostream> using namespace std;int main(){int a,b;cin>>a>>b;cout<<"ab"<<ab<<endl;return 0; } #include <iostream> 是 C 标准库中的头文件之一&#xff0c;它包含…

Python程序员的薪资待遇怎么样?我来讲述一下自己五年的工作经历

大家好&#xff0c;我是一名毕业五年的程序员。接下来我将讲述一下我的五年经历&#xff0c;以及一些关于程序员薪资的问题。大学时学的是计算机专业&#xff0c;当时选择这个专业是因为听说这个专业比较好找工作。于是我就选了计算机专业&#xff0c;然后在大四的时候开始找工…

权限提升:漏洞探针.(Linux系统)

权限提升&#xff1a;漏洞探针. 权限提升简称提权&#xff0c;由于操作系统都是多用户操作系统&#xff0c;用户之间都有权限控制&#xff0c;比如通过 Web 漏洞拿到的是 Web 进程的权限&#xff0c;往往 Web 服务都是以一个权限很低的账号启动的&#xff0c;因此通过 Webshel…

同一云厂商同一内网云服务器中使用CentOS 7.6安装Kubernetes集群

查看Linux发行版版本号 cat /etc/redhat-release查看版本号。 更改yum源 参考我写的博客。 主节点操作系统参数配置和软件安装 cat >> /etc/hosts <<OFF&#xff0c;将你的两台云服务器的内网IP和对应的主机名写到/etc/hosts。 需要修改“/etc/fstab”&…

Redis监控步骤get!Google精髓的四大法则直接掌握

Redis也是对外服务&#xff0c;所以Google四个黄金指标同样适用&#xff0c;还从延迟、流量、错误、饱和度分析Redis关键指标。 1 延迟 选择Redis是想得到更快响应速度和更高吞吐量&#xff0c;所以延迟数据对使用Redis的应用程序至关重要。 1.1 如何监控延迟 ① 客户端应用…

C++基础——运算符详解

详解运算符 初识运算符位运算认识位运算的相关运算符。能实现什么样的操作&#xff1f;及实现原理。 比较运算符逻辑运算符赋值运算符 初识运算符 运算符分为5大类&#xff1a;算数运算符、赋值运算符、复合赋值运算符、比较运算符、逻辑运算符。算数运算符就是加减乘除运算&a…

bytetrack 多目标跟踪 学习笔记

效果&#xff1a; ByteTrack在遮挡情况下ID不丢失演示 最近几天在看Bytetrack,感觉自己的学习方法有点问题, 应该先断点跑通&#xff0c;总体把握 然后完全理解算法代码的每一行,可以自己复现(这个就很难,看github的大佬把python转C 好羡慕) 所以如果学习Bytetrack,需要: 了…

【Git】Git(分布式项目管理工具)在Windows本地/命令行中的基本操作以及在gitee中的操作,使用命令行、图形化界面,进行提交,同步,克隆

介绍 这里是小编成长之路的历程&#xff0c;也是小编的学习之路。希望和各位大佬们一起成长&#xff01; 以下为小编最喜欢的两句话&#xff1a; 要有最朴素的生活和最遥远的梦想&#xff0c;即使明天天寒地冻&#xff0c;山高水远&#xff0c;路远马亡。 一个人为什么要努力&a…

菜鸡shader:L1基于兰伯特原理的玉石、条纹、点阵材质

目录 玉石材质条纹材质点阵效果 这里就简单说下原理吧&#xff0c;使用unity很久之前的一个插件shaderforge&#xff0c;最近几年好像在unity资源商店已经不再维护了&#xff0c;但是有shader forge的官网&#xff1a;在这&#xff0c;碰到节点不会的时候可以查一下官方文档&am…

手把手,带你发布你的第一个npm包

我们在编写项目的时候&#xff0c;都会引入很多很好用的工具包&#xff0c;例如VueX、axios、Router、Element UId等。这些包很好用&#xff0c;安装引入也很方便。那如果我们也想发布一个我们自己的工具包&#xff0c;在以后编写项目时&#xff0c;直接引入我们自己的工具包要…

干货 | 正确引用参考文献的6大技巧

Hello&#xff0c;大家好&#xff01; 这里是壹脑云科研圈&#xff0c;我是喵君姐姐&#xff5e; 对于学术研究而言&#xff0c;正确引用参考文献非常重要。参考文献不仅展现了自己的学术水平&#xff0c;同时也给研究定位&#xff0c;突显研究在前人研究基础上作出的贡献。 …

牛客_华为_HJ32 密码截取

HJ32 密码截取 647. 回文子串 516.最长回文子序列 ## 这不就是 最长回文子串&#xff01;&#xff01; ## 回文子串有两种模式 ## ABA ##CAACst input() n len(st) ## 双指针 def func(s,i,j,n):res 0while i>0 and j<n and s[i]s[j]:i-1j1return j-i-1 ## 由于i j…