今天来说说怎样在.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,代码如下:
<?xml version="1.0" encoding="utf-8" ?><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%20adjustX%20=%20(this.Width%20-%20PanWidth)%20/%202%20-%20_offsetX;var%20adjustY%20=%20(this.Height%20-%20PanHeight)%20/%202%20-%20_offsetY;Point%20p0%20=%20new%20Point(PanWidth%20/%202%20+%20adjustX,%20adjustY);Point%20p1%20=%20new%20Point(this.PanWidth%20+%20adjustX,%20this.PanHeight%20/%202%20+%20adjustY);Point%20p2%20=%20new%20Point(this.PanWidth%20/%202%20+%20adjustX,%20this.PanHeight%20+%20adjustY);Point%20p3%20=%20new%20Point(adjustX,%20this.PanHeight%20/%202%20+%20adjustY);
当改变控件和目标距离时,圆有了一种“不想离开”的感觉,此时模拟了圆的粘滞效果。
形变动画
当圆的形变超过容忍度时,圆会恢复到原始状态,此时需要一个动画,模拟回弹效果。
我们不必计算动画路径细节,只需要计算动画的起始点和终止点:
-
重新计算原始状态的贝塞尔曲线各点的位置作为终止点
-
贝塞尔曲线各点的当前位置,作为起始点
创建方法Animate,代码如下:
private%20void%20Animate(Action<double,%20bool>%20finished%20=%20null){%20%20%20%20Content.AbortAnimation("ReshapeAnimations");%20%20%20%20var%20scaleAnimation%20=%20new%20Animation();%20%20%20%20var%20adjustX%20=%20(this.Width%20-%20PanWidth)%20/%202;%20%20%20%20var%20adjustY%20=%20(this.Height%20-%20PanHeight)%20/%202;%20%20%20%20Point%20p0Target%20=%20new%20Point(PanWidth%20/%202%20+%20adjustX,%20adjustY);%20%20%20%20Point%20p1Target%20=%20new%20Point(this.PanWidth%20+%20adjustX,%20this.PanHeight%20/%202%20+%20adjustY);%20%20%20%20Point%20p2Target%20=%20new%20Point(this.PanWidth%20/%202%20+%20adjustX,%20this.PanHeight%20+%20adjustY);%20%20%20%20Point%20p3Target%20=%20new%20Point(adjustX,%20this.PanHeight%20/%202%20+%20adjustY);%20%20%20%20Point%20p0Origin%20=%20this.figure1.StartPoint;%20%20%20%20Point%20p1Origin%20=%20this.arc1.Point3;%20%20%20%20Point%20p2Origin%20=%20this.arc2.Point3;Point p3Origin = this.arc3.Point3;}
使用线性插值法,根据进度值r,计算各点坐标。线性插值法在之前的文章有介绍,或参考这里,此篇将不赘述。
var%20animateAction%20=%20(double%20r)%20=>{%20%20%20%20Point%20p0%20=%20new%20Point((p0Target.X%20-%20p0Origin.X)%20*%20r%20+%20p0Origin.X,%20(p0Target.Y%20-%20p0Origin.Y)%20*%20r%20+%20p0Origin.Y);%20%20%20%20Point%20p1%20=%20new%20Point((p1Target.X%20-%20p1Origin.X)%20*%20r%20+%20p1Origin.X,%20(p1Target.Y%20-%20p1Origin.Y)%20*%20r%20+%20p1Origin.Y);%20%20%20%20Point%20p2%20=%20new%20Point((p2Target.X%20-%20p2Origin.X)%20*%20r%20+%20p2Origin.X,%20(p2Target.Y%20-%20p2Origin.Y)%20*%20r%20+%20p2Origin.Y);%20%20%20%20Point%20p3%20=%20new%20Point((p3Target.X%20-%20p3Origin.X)%20*%20r%20+%20p3Origin.X,%20(p3Target.Y%20-%20p3Origin.Y)%20*%20r%20+%20p3Origin.Y);%20%20%20%20Point%20h1%20=%20new%20Point(p0.X%20-%20DifferenceX,%20p0.Y);%20%20%20%20Point%20h2%20=%20new%20Point(p0.X%20+%20DifferenceX,%20p0.Y);%20%20%20%20Point%20h3%20=%20new%20Point(p1.X,%20p1.Y%20-%20DifferenceY);%20%20%20%20Point%20h4%20=%20new%20Point(p1.X,%20p1.Y%20+%20DifferenceY);%20%20%20%20Point%20h5%20=%20new%20Point(p2.X%20+%20DifferenceX,%20p2.Y);%20%20%20%20Point%20h6%20=%20new%20Point(p2.X%20-%20DifferenceX,%20p2.Y);%20%20%20%20Point%20h7%20=%20new%20Point(p3.X,%20p3.Y%20+%20DifferenceY);%20%20%20%20Point%20h8%20=%20new%20Point(p3.X,%20p3.Y%20-%20DifferenceY);%20%20%20%20this.figure1.StartPoint%20=%20p0;%20%20%20%20this.arc1.Point1%20=%20h2;%20%20%20%20this.arc1.Point2%20=%20h3;%20%20%20%20this.arc1.Point3%20=%20p1;%20%20%20%20this.arc2.Point1%20=%20h4;%20%20%20%20this.arc2.Point2%20=%20h5;%20%20%20%20this.arc2.Point3%20=%20p2;%20%20%20%20this.arc3.Point1%20=%20h6;%20%20%20%20this.arc3.Point2%20=%20h7;%20%20%20%20this.arc3.Point3%20=%20p3;%20%20%20%20this.arc4.Point1%20=%20h8;%20%20%20%20this.arc4.Point2%20=%20h1;%20%20%20%20this.arc4.Point3%20=%20p0;};
将动画添加到Animation对象中,然后提交动画。
动画触发,将在400毫秒内完成圆的复原。
var%20scaleUpAnimation0%20=%20new%20Animation(animateAction,%200,%201);scaleAnimation.Add(0,%201,%20scaleUpAnimation0);scaleAnimation.Commit(this,%20"ReshapeAnimations",%2016,%20400,%20finished:%20finished);
效果如下:
可以使用自定义缓动函数调整动画效果, 在之前的文章介绍了自定义缓动函数,此篇将不赘述。
使用如下图像的函数曲线,可以使动画添加一个惯性回弹效果。

应用此函数,代码如下:
var mySpringOut = (double x) => (x - 1) * (x - 1) * ((5f + 1) * (x - 1) + 5) + 1;var scaleUpAnimation0 = new Animation(animateAction, 0, 1, mySpringOut);
运行效果如下,这使得这个带有粘性的圆的回弹过程更有质量感

如果你觉得这样不够“弹”
可以使用阻尼振荡函数作为动画自定义缓动函数,此函数拟合的图像如下:

运行效果如下:
创建手势控件
.NET%20MAUI%20跨平台框架包含了识别平移手势的功能,在之前的博文[MAUI%20项目实战]%20手势控制音乐播放器(二):手势交互中利用此功能实现了pan-pit拖拽系统。此篇将不赘述。
简单来说就是拖拽物(pan)体到坑(pit)中,手势容器控件PanContainer描述了pan运动和pit位置的关系,并在手势运动中产生一系列消息事件。
创建页面布局
新建.NET%20MAUI项目,命名StickyTab
在MainPage.xaml中添加如下代码:
<ContentPage.Content>%20%20%20%20<Grid>%20%20%20%20%20%20%20%20<Grid.RowDefinitions>%20%20%20%20%20%20%20%20%20%20%20%20<RowDefinition%20Height="200"%20/>%20%20%20%20%20%20%20%20%20%20%20%20<RowDefinition%20Height="1*"%20/>%20%20%20%20%20%20%20%20</Grid.RowDefinitions>%20%20%20%20%20%20%20%20<Grid%20Grid.Row="0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20BackgroundColor="#F1F1F1">%20%20%20%20%20%20%20%20%20%20%20%20<Grid%20x:Name="PitContentLayout"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20ZIndex="1">%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Grid.ColumnDefinitions>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<ColumnDefinition%20Width="1*"%20/>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<ColumnDefinition%20Width="1*"%20/>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<ColumnDefinition%20Width="1*"%20/>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20</Grid.ColumnDefinitions>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<controls1:PitGrid%20x:Name="NewTabPit"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PitName="NewTabPit"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20WidthRequest="100"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HeightRequest="200"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Grid.Column="0">%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Label%20%20%20x:Name="NewTabLabel"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20TextColor="Black"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontFamily="FontAwesome"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontSize="28"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HorizontalOptions="CenterAndExpand"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Margin="0"></Label>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Label%20%20Margin="0,100,0,0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Opacity="0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Text="新建标签页"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20TextColor="#6E6E6E"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontSize="18"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HorizontalOptions="CenterAndExpand"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20></Label>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20</controls1:PitGrid>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<controls1:PitGrid%20x:Name="RefreshPit"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PitName="RefreshPit"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20WidthRequest="100"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HeightRequest="200"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Grid.Column="1">%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Label%20%20%20x:Name="RefreshLabel"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20TextColor="Black"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontFamily="FontAwesome"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontSize="28"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HorizontalOptions="CenterAndExpand"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Margin="0"></Label>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Label%20%20Margin="0,100,0,0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Opacity="0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Text="刷新"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20TextColor="#6E6E6E"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontSize="18"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HorizontalOptions="CenterAndExpand"></Label>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20</controls1:PitGrid>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<controls1:PitGrid%20x:Name="CloseTabPit"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PitName="CloseTabPit"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20WidthRequest="100"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HeightRequest="200"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Grid.Column="2">%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Label%20%20%20x:Name="CloseTabLabel"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20TextColor="Black"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontFamily="FontAwesome"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontSize="28"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HorizontalOptions="CenterAndExpand"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Margin="0"></Label>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Label%20%20Margin="0,100,0,0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Opacity="0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Text="关闭标签页"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20TextColor="#6E6E6E"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20FontSize="18"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HorizontalOptions="CenterAndExpand"></Label>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20</controls1:PitGrid>%20%20%20%20%20%20%20%20%20%20%20%20</Grid>%20%20%20%20%20%20%20%20%20%20%20%20<controls1:PanContainer%20BackgroundColor="Transparent"%20ZIndex="0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20x:Name="DefaultPanContainer"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20OnTapped="DefaultPanContainer_OnOnTapped"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20AutoAdsorption="False"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PanScale="1.0"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20SpringBack="True"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PanScaleAnimationLength="100"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Orientation="Horizontal">%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<Grid%20PropertyChanged="BindableObject_OnPropertyChanged"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20VerticalOptions="Start"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HorizontalOptions="Start">%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20<controls:StickyPan%20x:Name="MainStickyPan"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Background="Transparent"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PanStrokeBrush="Transparent"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PanFillBrush="White"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20AnimationLength="400"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PanHeight="80"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20PanWidth="80"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20HeightRequest="120"%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20WidthRequest="120">%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20</controls:StickyPan>%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20</Grid>%20%20%20%20%20%20%20%20%20%20%20%20</controls1:PanContainer>%20%20%20%20%20%20%20%20</Grid>%20%20%20%20</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%20void%20PanActionHandler(object%20recipient,%20PanActionArgs%20args){%20%20%20%20switch%20(args.PanType)%20%20%20%20{%20%20%20%20%20%20%20%20case%20PanType.Out:%20%20%20%20%20%20%20%20%20%20%20%20tipLabel%20=%20args.CurrentPit?.Children.LastOrDefault()%20as%20Label;%20%20%20%20%20%20%20%20%20%20%20%20if%20(tipLabel!=null)%20%20%20%20%20%20%20%20%20%20%20%20{%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20tipLabel.FadeTo(0);%20%20%20%20%20%20%20%20%20%20%20%20}%20%20%20%20%20%20%20%20%20%20%20%20break;%20%20%20%20%20%20%20%20case%20PanType.In:%20%20%20%20%20%20%20%20%20%20%20%20tipLabel%20=%20args.CurrentPit?.Children.LastOrDefault()%20as%20Label;%20%20%20%20%20%20%20%20%20%20%20%20if%20(tipLabel!=null)%20%20%20%20%20%20%20%20%20%20%20%20{%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20tipLabel.FadeTo(1);%20%20%20%20%20%20%20%20%20%20%20%20}%20%20%20%20%20%20%20%20%20%20%20%20break;%20%20%20%20%20%20%20%20case%20PanType.Over:%20%20%20%20%20%20%20%20%20%20%20%20tipLabel.FadeTo(0);%20%20%20%20%20%20%20%20%20%20%20%20ShowLayout(0);%20%20%20%20%20%20%20%20%20%20%20%20break;%20%20%20%20%20%20%20%20case%20PanType.Start:%20%20%20%20%20%20%20%20%20%20%20%20ShowLayout();%20%20%20%20%20%20%20%20%20%20%20%20break;%20%20%20%20}%20%20%20%20_currentDefaultPit%20=%20args.CurrentPit;}private%20void%20ShowLayout(double%20opacity%20=%201){%20%20%20%20var%20length%20=%20opacity==1%20?%20250%20:%200;%20%20%20%20this.DefaultPanContainer.FadeTo(opacity,%20(uint)length);}
最终效果如下:
新闻类标签交互部分与Chrome下拉标签页交互类似,此篇将不展开讲解。
最终效果如下:

项目地址
Github:maui-samples:
https://github.com/jevonsflash/maui-samples