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

news2024/10/6 16:26:42

文章目录

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

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

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

相关文章

华为OD机试真题(Java),数组拼接(100%通过+复盘思路)

一、题目描述 现在有多组整数数组,需要将它们合并成一个新的数组。 合并规则,从每个数组里按顺序取出固定长度的内容合并到新的数组中,取完的内容会删除掉,如果该行不足固定长度或者已经为空,则直接取出剩余部分的内容放到新的数组中,继续下一行。 二、输入描述 第一…

【Python入门篇】——PyCharm的基础使用

作者简介&#xff1a; 辭七七&#xff0c;目前大一&#xff0c;正在学习C/C&#xff0c;Java&#xff0c;Python等 作者主页&#xff1a; 七七的个人主页 文章收录专栏&#xff1a; Python入门&#xff0c;本专栏主要内容为Python的基础语法&#xff0c;Python中的选择循环语句…

Mysql Sharding-JDBC读写分离

0 课程视频 深入Sharding-JDBC分库分表从入门到精通【黑马程序员】_哔哩哔哩_bilibili 1 基本概念 1.1应用逻辑 1.1.1 msyql 多库 多表 多服务器 1.1.2 通过Sharding-JDBC jar包->增强JDBC 访问多数据源 -> 自动处理成一个数据源 1.1.3 使用数据的人 -> 使用Sh…

Python+Yolov5墙体裂缝识别

程序示例精选 PythonYolov5墙体裂缝识别 如需安装运行环境或远程调试&#xff0c;见文章底部个人QQ名片&#xff0c;由专业技术人员远程协助&#xff01; 前言 这篇博客针对<<PythonYolov5墙体裂缝识别>>编写代码&#xff0c;代码整洁&#xff0c;规则&#xff0c…

C++类的默认成员函数

文章目录 默认函数构造函数和析构函数构造函数析构函数 拷贝构造函数运算符重载赋值运算符重载赋值运算符重载和构造函数 默认函数 什么是默认函数&#xff1f; 默认函数就是当你使用这个类对象时,这个类会自动调用的函数C中有六个默认成员函数,并且作用各不相同,下面我们来一…

2 ROS2话题通讯基础

2 ROS2话题通讯基础 2.1 ROS2话题通讯介绍2.2 ROS2常用的消息类型介绍2.2.1 std_msgs消息类型2.2.2 geometry_msgs消息类型 2.3 使用C/C创建基础消息类型的话题通讯2.3.1 创建C/C发布话题信息的功能包并配置VSCode环境2.3.2 编写ROS2发布话题节点CPP文件2.3.3 配置C/C发布话题功…

数据结构学习记录——堆的插入(堆的结构类型定义、最大堆的创建、堆的插入:堆的插入的三种情况、哨兵元素)

目录 堆的结构类型定义 最大堆的创建 堆的插入 堆的插入的三种情况 代码实现 哨兵元素 堆的结构类型定义 #define ElementType int typedef struct HNode* Heap; /* 堆的类型定义 */ struct HNode {ElementType* Data; /* 存储元素的数组 */int Size; /* 堆中…

『python爬虫』08. 数据解析之bs4解析(保姆级图文)

目录 1. 安装bs4模块find()findall() 2. 爬取信息测试总结 欢迎关注 『python爬虫』 专栏&#xff0c;持续更新中 欢迎关注 『python爬虫』 专栏&#xff0c;持续更新中 1. 安装bs4模块 pip install bs4 pip install-i https://pypi.tuna.tsinghua.edu.cn/simplebs4如果遇到报…

[网络原理] HTTP协议

要珍惜时间呀 文章目录 1. HTTP协议概念2. HTTP协议格式2.URL3. GET与POST方法3.1 GET方法3.2 POST方法3.3 GET与POST的区别 1. HTTP协议概念 HTTP协议是应用层协议,TCP/IP协议为传输层协议,负责传输数据.而HTTP协议相当于对传输的数据据怎样处理和使用进行说明. 每次,我们访问…

Mininet+Ryu安装教程

最近要做一个Mininet的网络环境&#xff0c;网络设备由Mininet来模拟&#xff0c;SDN控制器用Ryu来做&#xff0c;为了避免每次重新做再去翻查资料&#xff0c;我在这里系统地整理一遍 硬件需求 我在 VMWare Workstation 16 Player虚拟机上运行的Ubuntu 22.04.1 硬件需求内存…

供应链 | 需求不确定情况下的物料需求规划: 基于随机优化的研究

作者&#xff1a;Simon Thevenin, Yossiri Adulyasak, Jean-Francois Cordeau​ 引用&#xff1a;Thevenin S, Adulyasak Y, Cordeau J F. Material requirements planning under demand uncertainty using stochastic optimization[J]. Production and Operations Management,…

react的项目实战 2

入口文件引入了app这个组件 app这个组件又引入了header这个组件。 然后外面引入这个组件 进行页面的显示 它不会影响到其他页面的组件的样式。 ​​​​​​​

面试必备:接口自动化测试精选面试题大全

目录 一、 请问你是如何做接口测试的&#xff1f; 二、接口测试如何设计测试用例&#xff1f; 三、接口测试执行中需要比对数据库吗&#xff1f; 四、接口测试质量评估标准是什么&#xff1f; 五、接口产生的垃圾数据如何清理 六、其他接口要先获取接口信息&#xff0c;如…

利用wenda实现本地多模态数据的知识获取和推理

近年来&#xff0c;大型语言模型&#xff08;LLM&#xff09;技术取得了令人瞩目的进展&#xff0c;为自然语言处理领域带来了巨大的变革&#xff0c;但是大多数LLM都面临着领域适应性的问题&#xff0c;因为它们使用的数据都是公开的数据&#xff0c;在国内&#xff0c;有很多…

Day960.架构现代化-微服务 -遗留系统现代化实战

架构现代化-微服务 Hi&#xff0c;我是阿昌&#xff0c;今天学习记录的是关于架构现代化-微服务的内容。 在自治气泡模式的基础上&#xff0c;通过事件拦截来实现数据同步&#xff0c;给气泡和遗留系统之间又加上 API 访问这个通信渠道。 这时的自治气泡就和真正的微服务差不…

Rust - 变量与数据的交互方式(move)

变量与数据的交互方式 - 移动 Rust 中的多个变量可以采用一种比较独特的方式和同一个数据进行交互&#xff0c;如下代码所示&#xff0c;将变量x的值赋给y&#xff1a; fn main() {let x 1;let y x; }我们大概可以推论出上述代码的原理&#xff1a;将1这个整数绑定给x变量&…

Mybatis读取和存储json类型的数据

目录 一、测试使用JSONObject来获取json二、设置TableName的autoResultMap为true&#xff0c;TableField的typeHandler为JacksonTypeHandler.class三、设置xml当中的resultMap四、JacksonTypeHandler讲解五、新增假如是JSONObject 不管数据库当中是以json还是longtext数据类型来…

树莓派Opencv调用摄像头(Raspberry Pi 11)

前言&#xff1a;本人初玩树莓派opencv&#xff0c;使用的是树莓派Raspberry Pi OS 11&#xff0c;系统若不一致请慎用&#xff0c;本文主要记录在树莓派上通过Opencv打开摄像头的经验。 1、系统版本 进入树莓派&#xff0c;打开终端输入以下代码&#xff08;查看系统的版本&…

“人工智能教父”从谷歌离职 称后悔发展AI,为世人敲响警钟?

在加入谷歌的第十年、深度学习迎来爆发式发展的当下&#xff0c;被誉为“人工智能教父”的Geoffrey Hinton已从谷歌离职&#xff0c;只是为了告诫人们AI已经变得很危险。 公开资料显示&#xff0c;Geoffrey Hinton在2013年加入谷歌&#xff0c;曾任副总裁&#xff0c;研究机器学…

Python每日一练:硬币的面值奇偶排序陶陶摘苹果(花样解法)

Python每日一练 文章目录 Python每日一练前言一、硬币的面值二、奇偶排序三、陶陶摘苹果总结 前言 很显然&#xff0c;Python的受众远远大于C&#xff0c;其实笔者本人对Python的理解也是远强于C的&#xff0c;C纯粹是为了假装笔者是个职业选手才随便玩玩的&#xff0c;借着十…