@
比如在手機版的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 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下拉分頁互動類似,此篇將不展開講解。
最終效果如下:
本文來自部落格園,作者:林曉lx,轉載請註明原文連結:https://www.cnblogs.com/jevonsflash/p/17438596.html