• [MAUI]模仿Chrome下拉标签页的交互实现


    @


    今天来说说怎样在.NET MAUI 中制作一个灵动的类标签页控件,这类控件常用于页面中多个子页面的导航功能。

    比如在手机版的Chrome中,当用户在网页中下拉时将出现“新建标签页”,“刷新”,“关闭标签页”三个选项,通过不间断的横向手势滑动,可以在这三个选项之间切换。选项指示器是一个带有粘滞效果的圆,如下图:

    在这里插入图片描述
    图 - iOS版Edge浏览器下拉刷新功能

    浏览网页常用选项融入到了原“下拉刷新”交互中,对比传统交互方式它更显便捷和流畅,根据Steve Krug之《Don't Make Me Think》的核心思想,用户无需思考点击次序,只需要使用基础动作就能完成交互。

    今天在.NET MAUI 中实现Chrome下拉标签页交互,以及常见的新闻类App中的标签页切换交互
    ,最终效果如下:

    在这里插入图片描述
    在这里插入图片描述

    使用.NET MAU实现跨平台支持,本项目可运行于Android、iOS平台。

    创建粘滞效果的圆控件

    粘滞效果模仿了水滴,或者“史莱姆”等等这种粘性物质受外力作用的形变效果。

    要实现此效果,首先请出我们的老朋友——贝塞尔曲线,二阶贝塞尔曲线可以根据三点:起始点、终止点(也称锚点)、控制点绘制出一条平滑的曲线,利用多段贝塞尔曲线函数,可以拟合出一个圆。

    通过微调各曲线的控制点,可以使圆产生形变效果,即模仿了粘滞效果。

    贝塞尔曲线绘制圆

    用贝塞尔曲线无法完美绘制出圆,只能无限接近圆。

    对于n的贝塞尔曲线,到曲线控制点的最佳距离是(4/3)*tan(pi/(2n)),详细推导过程可以查看这篇文章https://spencermortensen.com/articles/bezier-circle/

    在这里插入图片描述

    因此,对于4分,它是(4/3)tan(pi/8) = 4(sqrt(2)-1)/3 = 0.552284749831。

    在这里插入图片描述

    创建控件

    我们创建控件StickyPan,在Xaml部分,我们创建一个包含四段BezierSegment的Path,代码如下:

    
    <ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 SizeChanged="ContentView_SizeChanged"
                 Background="white"            
                 x:Class="StickyTab.Controls.StickyPan">
        <Grid>
            <Path x:Name="MainPath">
                <Path.Data>
                    <PathGeometry>
                        <PathFigure x:Name="figure1" Stroke="red">
                            <PathFigure.Segments>
                                <PathSegmentCollection>
                                    <BezierSegment x:Name="arc1" />
                                    <BezierSegment x:Name="arc2" />
                                    <BezierSegment x:Name="arc3" />
                                    <BezierSegment x:Name="arc4" />
                                PathSegmentCollection>
                            PathFigure.Segments>
                        PathFigure>
                    PathGeometry>
                Path.Data>
         
            Path>
        Grid>
    ContentView>
    

    我们对4段贝塞尔曲线的各起始点、终止点以及控制点定义如下

    在这里插入图片描述

    请记住这些点的名称,在给圆添加形变时会引用这些点。

    圆的大小为控件的宽高,圆心为控件的中心点。根据公式,我们计算出控制点的偏移量

    private double C = 0.552284749831f;
    
    public double RadiusX => this.Width/2;
    public double RadiusY => this.Height/2;
    public Point Center => new Point(this.Width/2, this.Height/2);
    
    public double DifferenceX => RadiusX * C;
    public double DifferenceY => RadiusY * C;
    
    
    

    根据控制点偏移量计算出各控制点的坐标
    以及贝塞尔曲线的起始点和终止点:

    Point p0 = new Point(Width/2, 0);
    Point h1 = new Point(Width/2-DifferenceX, 0);
    Point h2 = new Point(this.Width/2+DifferenceX, 0);
    Point h3 = new Point(this.Width, this.Height/2-  DifferenceY);
    Point p1 = new Point(this.Width, this.Height/2);
    Point h4 = new Point(this.Width, this.Height/2+DifferenceY);
    Point h5 = new Point(this.Width/2+DifferenceX, this.Height);
    Point p2 = new Point(this.Width/2, this.Height);
    Point h6 = new Point(this.Width/2-DifferenceX, this.Height);
    Point h7 = new Point(0, this.Height/2+DifferenceY);
    Point p3 = new Point(0, this.Height/2);
    Point h8 = new Point(0, this.Height/2-DifferenceY);
    
    

    如此,我们便绘制了一个圆

    this.figure1.StartPoint =  p0;
    
    this.arc1.Point1 = h2;
    this.arc1.Point2 = h3;
    this.arc1.Point3 = p1;
    
    
    this.arc2.Point1 = h4;
    this.arc2.Point2 = h5;
    this.arc2.Point3 = p2;
    
    this.arc3.Point1 = h6;
    this.arc3.Point2 = h7;
    this.arc3.Point3 = p3;
    
    this.arc4.Point1 = h8;
    this.arc4.Point2 = h1;
    
    this.arc4.Point3 = p0;
    

    效果如下:

    在这里插入图片描述

    创建形变

    现在想象这个圆是一颗水珠,假设我们要改变圆的形状,形成向右的“水滴状”。

    水的体积是不会变的,当一边发生扩张形变,相邻的两边必定收缩形变。

    假设x方向的形变量为dy,y方向的形变量为dx,收缩形变系数为0.4,扩张形变系数为0.8,应用到p0、p1、p2、p3的点坐标变化如下:

    
    var dx = 400*0.8;
    var dy = 400*0.4;
    p0= p0.Offset(0, Math.Abs(dy));
    p1= p1.Offset(dx, 0);
    p2 = p2.Offset(0, -Math.Abs(dy));
    

    p0变换后的坐标为p0',p1变换后的坐标为p1',p2变换后的坐标为p2'。
    变换前后的对比如下:

    在这里插入图片描述

    可控形变

    请注意,上一小节提到的形变量dx、dy是固定的,我们需要将形变量变为可变,这样才能实现水滴的形变。

    我们定义两个变量_offsetX、_offsetY,用于控制形变量的大小。计算形变量的正负值确定形变的方向。不同方向上平移作用的点不同,计算出各点的坐标变化如下:

    var dx = _offsetX * 0.8 + _offsetY * 0.4;
    var dy = _offsetX * 0.4 + _offsetY * 0.8;
    if (_offsetX != 0)
    {
        if (dx > 0)
        {
            p1 = p1.Offset(dx, 0);
    
        }
        else
        {
            p3 = p3.Offset(dx, 0);
        }
        p0 = p0.Offset(0, Math.Abs(dy));
        p2 = p2.Offset(0, -Math.Abs(dy));
    }
    
    if (_offsetY != 0)
    {
        if (dy > 0)
        {
            p2 = p2.Offset(0, dy);
        }
    
        else
        {
            p0 = p0.Offset(0, dy);
        }
        p1 = p1.Offset(-Math.Abs(dx), 0);
        p3 = p3.Offset(Math.Abs(dx), 0);
    
    }
    

    这样在x,y方向可以产生自由形变

    在这里插入图片描述

    注意此时我们引入了PanWidth、PanHeight两个属性描述圆的尺寸,因为圆会发生扩张形变,圆的边缘不应该再为控件边缘

    public double RadiusX => this.PanWidth / 2;
    public double RadiusY => this.PanHeight / 2;
    
    
    //圆形居中补偿
    var adjustX = (this.Width - PanWidth) / 2 ;
    var adjustY = (this.Height - PanHeight) / 2 ;
    
    Point p0 = new Point(PanWidth / 2 + adjustX, adjustY);
    Point p1 = new Point(this.PanWidth + adjustX, this.PanHeight / 2 + adjustY);
    Point p2 = new Point(this.PanWidth / 2 + adjustX, this.PanHeight + adjustY);
    Point p3 = new Point(adjustX, this.PanHeight / 2 + adjustY);
    

    形变边界

    首先确定一个“容忍度”,当形变量超过容忍度时,不再产生形变,这样可以避免形变过大,导致圆形形变过渡。

    这个容忍度将由控件到目标点的距离决定,可以想象这个粘稠的水滴在粘连时,距离越远,粘连越弱。当距离超过容忍度时,粘连就会断开。

    此时offsetX、offsetY正好可以代表这个距离,我们可以通过offsetX、offsetY计算出距离,然后与容忍度比较,超过容忍度则将不黏连。

    var _offsetX = OffsetX;
    //超过容忍度则将不黏连
    if (OffsetX <= -(this.Width - PanWidth) / 2 || OffsetX > (this.Width - PanWidth) / 2)
    {
        _offsetX = 0;
    }
    
    var _offsetY = OffsetY;
    //超过容忍度则将不黏连
    if (OffsetY <= -(this.Height - PanHeight) / 2 || OffsetY > (this.Height - PanHeight) / 2)
    {
        _offsetY = 0;
    }
    
    

    容忍度不应超过圆边界到控件边界的距离,此处为±50;

    在这里插入图片描述

    因为是黏连,所以在容忍度范围内,要模拟粘连的效果,圆发生形变时,实际上是力作用于圆上的点,所以是圆上的点发生位移,而不是圆本身。

    将offsetX和offsetY考虑进补偿偏移量计算,重新计算贝塞尔曲线各点的坐标

    var adjustX = (this.Width - PanWidth) / 2 - _offsetX;
    var adjustY = (this.Height - PanHeight) / 2 - _offsetY;
    
    Point p0 = new Point(PanWidth / 2 + adjustX, adjustY);
    Point p1 = new Point(this.PanWidth + adjustX, this.PanHeight / 2 + adjustY);
    Point p2 = new Point(this.PanWidth / 2 + adjustX, this.PanHeight + adjustY);
    Point p3 = new Point(adjustX, this.PanHeight / 2 + adjustY);
    

    在这里插入图片描述

    当改变控件和目标距离时,圆有了一种“不想离开”的感觉,此时模拟了圆的粘滞效果。

    形变动画

    当圆的形变超过容忍度时,圆会恢复到原始状态,此时需要一个动画,模拟回弹效果。

    我们不必计算动画路径细节,只需要计算动画的起始点和终止点:

    • 重新计算原始状态的贝塞尔曲线各点的位置作为终止点

    • 贝塞尔曲线各点的当前位置,作为起始点

    创建方法Animate,代码如下:

    private void Animate(Action<double, bool> finished = null)
    {
        Content.AbortAnimation("ReshapeAnimations");
        var scaleAnimation = new Animation();
    
    
        var adjustX = (this.Width - PanWidth) / 2;
        var adjustY = (this.Height - PanHeight) / 2;
    
        Point p0Target = new Point(PanWidth / 2 + adjustX, adjustY);
        Point p1Target = new Point(this.PanWidth + adjustX, this.PanHeight / 2 + adjustY);
        Point p2Target = new Point(this.PanWidth / 2 + adjustX, this.PanHeight + adjustY);
        Point p3Target = new Point(adjustX, this.PanHeight / 2 + adjustY);
    
        Point p0Origin = this.figure1.StartPoint;
        Point p1Origin = this.arc1.Point3;
        Point p2Origin = this.arc2.Point3;
        Point p3Origin = this.arc3.Point3;
    
        ...
    }
    

    使用线性插值法,根据进度值r,计算各点坐标。线性插值法在之前的文章有介绍,或参考这里,此篇将不赘述。

    var animateAction = (double r) =>
    {
    
        Point p0 = new Point((p0Target.X - p0Origin.X) * r + p0Origin.X, (p0Target.Y - p0Origin.Y) * r + p0Origin.Y);
        Point p1 = new Point((p1Target.X - p1Origin.X) * r + p1Origin.X, (p1Target.Y - p1Origin.Y) * r + p1Origin.Y);
        Point p2 = new Point((p2Target.X - p2Origin.X) * r + p2Origin.X, (p2Target.Y - p2Origin.Y) * r + p2Origin.Y);
        Point p3 = new Point((p3Target.X - p3Origin.X) * r + p3Origin.X, (p3Target.Y - p3Origin.Y) * r + p3Origin.Y);
    
        Point h1 = new Point(p0.X - DifferenceX, p0.Y);
        Point h2 = new Point(p0.X + DifferenceX, p0.Y);
        Point h3 = new Point(p1.X, p1.Y - DifferenceY);
        Point h4 = new Point(p1.X, p1.Y + DifferenceY);
        Point h5 = new Point(p2.X + DifferenceX, p2.Y);
        Point h6 = new Point(p2.X - DifferenceX, p2.Y);
        Point h7 = new Point(p3.X, p3.Y + DifferenceY);
        Point h8 = new Point(p3.X, p3.Y - DifferenceY);
    
    
        this.figure1.StartPoint = p0;
        this.arc1.Point1 = h2;
        this.arc1.Point2 = h3;
        this.arc1.Point3 = p1;
    
    
        this.arc2.Point1 = h4;
        this.arc2.Point2 = h5;
        this.arc2.Point3 = p2;
    
        this.arc3.Point1 = h6;
        this.arc3.Point2 = h7;
        this.arc3.Point3 = p3;
    
        this.arc4.Point1 = h8;
        this.arc4.Point2 = h1;
    
        this.arc4.Point3 = p0;
    };
    
    

    将动画添加到Animation对象中,然后提交动画。

    动画触发,将在400毫秒内完成圆的复原。

    var scaleUpAnimation0 = new Animation(animateAction, 0, 1);
    scaleAnimation.Add(0, 1, scaleUpAnimation0);
    scaleAnimation.Commit(this, "ReshapeAnimations", 16, 400, finished: finished);
    
    

    效果如下:

    在这里插入图片描述

    可以使用自定义缓动函数调整动画效果, 在之前的文章介绍了自定义缓动函数,此篇将不赘述。

    使用如下图像的函数曲线,可以使动画添加一个惯性回弹效果。

    在这里插入图片描述

    应用此函数,代码如下:

    var mySpringOut = (double x) => (x - 1) * (x - 1) * ((5f + 1) * (x - 1) + 5) + 1;
    var scaleUpAnimation0 = new Animation(animateAction, 0, 1, mySpringOut);
    ...
    

    运行效果如下,这使得这个带有粘性的圆的回弹过程更有质量感

    在这里插入图片描述

    如果你觉得这样不够“弹”

    可以使用阻尼振荡函数作为动画自定义缓动函数,此函数拟合的图像如下:

    在这里插入图片描述

    运行效果如下:

    在这里插入图片描述

    创建手势控件

    .NET MAUI 跨平台框架包含了识别平移手势的功能,在之前的博文[MAUI 项目实战] 手势控制音乐播放器(二): 手势交互中利用此功能实现了pan-pit拖拽系统。此篇将不赘述。

    简单来说就是拖拽物(pan)体到坑(pit)中,手势容器控件PanContainer描述了pan运动和pit位置的关系,并在手势运动中产生一系列消息事件。

    创建页面布局

    新建.NET MAUI项目,命名StickyTab

    MainPage.xaml中添加如下代码:

    <ContentPage.Content>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="200" />
                <RowDefinition Height="1*" />
            Grid.RowDefinitions>
            <Grid Grid.Row="0"
                    BackgroundColor="#F1F1F1">
                <Grid x:Name="PitContentLayout"
                        ZIndex="1">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="1*" />
                        <ColumnDefinition Width="1*" />
                        <ColumnDefinition Width="1*" />
                    Grid.ColumnDefinitions>
    
                    <controls1:PitGrid x:Name="NewTabPit"
                                        PitName="NewTabPit"
                                        WidthRequest="100"
                                        HeightRequest="200"
                                        Grid.Column="0">
    
                        <Label   x:Name="NewTabLabel"
                                    TextColor="Black"
                                    FontFamily="FontAwesome"
                                    FontSize="28"
                                    HorizontalOptions="CenterAndExpand"
                                    Margin="0">Label>
                        <Label  Margin="0,100,0,0"
                                Opacity="0"
                                    Text="新建标签页"
                                    TextColor="#6E6E6E"
                                    FontSize="18"
                                    HorizontalOptions="CenterAndExpand"
                                >Label>
    
    
                    controls1:PitGrid>
                    <controls1:PitGrid x:Name="RefreshPit"
                                        PitName="RefreshPit"
                                        WidthRequest="100"
                                        HeightRequest="200"
                                        Grid.Column="1">
    
                        <Label   x:Name="RefreshLabel"
                                    TextColor="Black"
                                    FontFamily="FontAwesome"
                                    FontSize="28"
                                    HorizontalOptions="CenterAndExpand"
                                    Margin="0">Label>
                        <Label  Margin="0,100,0,0"
                                Opacity="0"
                                Text="刷新"
                                TextColor="#6E6E6E"
                                FontSize="18"
                                HorizontalOptions="CenterAndExpand">Label>
                    controls1:PitGrid>
                    <controls1:PitGrid x:Name="CloseTabPit"
                                        PitName="CloseTabPit"
                                        WidthRequest="100"
                                        HeightRequest="200"
                                        Grid.Column="2">
    
                        <Label   x:Name="CloseTabLabel"
                                    TextColor="Black"
                                    FontFamily="FontAwesome"
                                    FontSize="28"
                                    HorizontalOptions="CenterAndExpand"
                                    Margin="0">Label>
                        <Label  Margin="0,100,0,0"
                                Opacity="0"
                                Text="关闭标签页"
                                TextColor="#6E6E6E"
                                FontSize="18"
                                HorizontalOptions="CenterAndExpand">Label>
                    controls1:PitGrid>
                Grid>
                <controls1:PanContainer BackgroundColor="Transparent" ZIndex="0"
                                        x:Name="DefaultPanContainer"
                                        OnTapped="DefaultPanContainer_OnOnTapped"
                                        AutoAdsorption="False"
                                        PanScale="1.0"
                                        SpringBack="True"
                                        PanScaleAnimationLength="100"
                                        Orientation="Horizontal">
    
                    <Grid PropertyChanged="BindableObject_OnPropertyChanged"
                            VerticalOptions="Start"
                            HorizontalOptions="Start">
    
                        <controls:StickyPan x:Name="MainStickyPan"
                                            Background="Transparent"
                                            PanStrokeBrush="Transparent"
                                            PanFillBrush="White"
                                            AnimationLength="400"
                                            PanHeight="80"
                                            PanWidth="80"
                                            HeightRequest="120"
                                            WidthRequest="120">
                            
                            
                            
                        controls:StickyPan>
    
                    Grid>
    
    
                controls1:PanContainer>
    
            Grid>
        Grid>
    ContentPage.Content>
    
    

    页面布局看起来像这样:

    在这里插入图片描述

    更新拖拽物位置

    在Xaml中我们订阅了PropertyChanged事件,当拖拽物的位置发生变化时,我们需要更新拖拽系统中目标坑的位置。

    _currentDefaultPit变量用于记录当前拖拽物所在的坑,当拖拽物离开坑时,我们需要将其设置为null。

    private PitGrid _currentDefaultPit;
    
    
    private void BindableObject_OnPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(Width))
        {
            this.DefaultPanContainer.PositionX = (this.PitContentLayout.Width - (sender as Grid).Width) / 2;
        }
        else if (e.PropertyName == nameof(Height))
        {
            this.DefaultPanContainer.PositionY = (this.PitContentLayout.Height - (sender as Grid).Height) / 2;
    
        }
        else if (e.PropertyName == nameof(TranslationX))
        {
            var centerX = 0.0;
            if (_currentDefaultPit != null)
            {
                centerX = _currentDefaultPit.X + _currentDefaultPit.Width / 2;
            }
            this.MainStickyPan.OffsetX = this.DefaultPanContainer.Content.TranslationX + this.DefaultPanContainer.Content.Width / 2 - centerX;
    
        }
    }
    
    

    如下动图说明了目标坑变化时的效果,当拖拽物离开“刷新”时,粘滞效果的目标坑转移到了“新建标签页”上,接近“新建标签页”时产生对它的粘滞效果

    在这里插入图片描述

    其它细节

    在拖拽物之于坑的状态改变时,显示或隐藏拖拽物本身以及提示文本

    private void PanActionHandler(object recipient, PanActionArgs args)
    {
        switch (args.PanType)
        {
            case PanType.Out:
                tipLabel = args.CurrentPit?.Children.LastOrDefault() as Label;
                if (tipLabel!=null)
                {
                    tipLabel.FadeTo(0);
                }
                break;
            case PanType.In:
                tipLabel = args.CurrentPit?.Children.LastOrDefault() as Label;
                if (tipLabel!=null)
                {
                    tipLabel.FadeTo(1);
                }
                break;
            case PanType.Over:
                tipLabel.FadeTo(0);
                ShowLayout(0);
                break;
            case PanType.Start:
                ShowLayout();
                break;
        }
        _currentDefaultPit = args.CurrentPit;
    
    }
    
    private void ShowLayout(double opacity = 1)
    {
        var length = opacity==1 ? 250 : 0;
        this.DefaultPanContainer.FadeTo(opacity, (uint)length);
    }
    
    
    

    最终效果如下:

    在这里插入图片描述
    新闻类标签交互部分与Chrome下拉标签页交互类似,此篇将不展开讲解。
    最终效果如下:
    在这里插入图片描述

    项目地址

    Github:maui-samples

  • 相关阅读:
    【Anubis-阿努比斯】在Linux下的使用步骤
    ATV32变频器在堆垛机应用
    STM32定时器篇——通用定时器的使用(定时中断,PWM输出)
    人声分离神仙网站,用过都说好~
    Web前端工程师应该掌握哪些技能及知识点?
    [附源码]计算机毕业设计甜品购物网站Springboot程序
    计算机毕设 机器学习新闻算法实现 - python机器学习 深度学习
    Go语言零基础入门,make、append、struct结构体
    React Profiler 性能优化工具
    工业交换机选用技巧
  • 原文地址:https://www.cnblogs.com/jevonsflash/p/17438596.html