[MAUI 專案實戰] 手勢控制音樂播放器(二): 手勢互動

2023-04-08 21:01:04

@

原理

定義一個拖拽物,和它拖拽的目標,拖拽物可以理解為一個平底鍋(pan),拖拽目標是一個坑(pit),當拖拽物進入坑時,拖拽物就會被吸附在坑裡。可以腦補一下下圖:

你問我為什麼是平底鍋和坑,當然了在微軟官方的寫法裡pan是平移的意思,而不是指代平底鍋。只是通過同義詞來方便理解
坑就是正好是平底鍋大小的爐灶。正好可以放入平底鍋。

pan和pit組成平移手勢的系統,在具體程式碼中包含了邊緣檢測判定和狀態機維護。我們將一步步實現平移手勢功能

pit很簡單,是一個包含了名稱屬性的控制元件,這個名稱屬性是用來標識pit的。以便當pan入坑時我們知道入了哪個坑,IsEnable是一個繫結屬性,它用來控制pit是否可用的。

在這個程式中,拖拽物是一個抽象的唱盤。它的拖拽目標是周圍8個圖示。

互動實現

這裡用Grid作為pit控制元件基本類型,因為Grid可以包含子控制元件,我們可以在pit控制元件中新增子控制元件,比如一個圖片,一個文字,這樣就可以讓pit控制元件更加豐富。


public class PitGrid : Grid
{
    public PitGrid()
    {
        IsEnable = true;
    }

    public static readonly BindableProperty IsEnableProperty =
        BindableProperty.Create("IsEnable", typeof(bool), typeof(CircleSlider), true, propertyChanged: (bindable, oldValue, newValue) =>
        {
            var obj = (PitGrid)bindable;
            obj.Opacity = obj.IsEnable ? 1 : 0.8;

        });

    public bool IsEnable
    {
        get { return (bool)GetValue(IsEnableProperty); }
        set { SetValue(IsEnableProperty, value); }
    }

    public string PitName { get; set; }

}

使用WeakReferenceMessenger作為訊息中心,用來傳遞pan和pit的互動資訊。

定義一個平移事件PanAction,在pan和pit產生交匯時觸發。其引數PanActionArgs描述了pan和pit的互動的關係和狀態。

public class PanActionArgs
{
    public PanActionArgs(PanType type, PitGrid pit = null)
    {
        PanType = type;
        CurrentPit = pit;
    }
    public PanType PanType { get; set; }
    public PitGrid CurrentPit { get; set; }

}

手勢狀態型別PanType定義如下:

  • In:pan進入pit時觸發,
  • Out:pan離開pit時觸發,
  • Over:釋放pan時觸發,
  • ·Start:pan開始拖拽時觸發
public enum PanType
{
    Out, In, Over, Start
}

MAUI為我們開發者包裝好了PanGestureRecognizer 即平移手勢識別器。

平移手勢更改時引發事件PanUpdated事件,此事件附帶的 PanUpdatedEventArgs物件中包含以下屬性:

  • StatusType,型別 GestureStatus為 ,指示是否為新啟動的手勢、正在執行的手勢、已完成的手勢或取消的手勢引發了事件。
  • TotalX,型別 double為 ,指示自手勢開始以來 X 方向的總變化。
  • TotalY,型別 double為 ,指示自手勢開始以來 Y 方向的總變化。

容器控制元件

PanGestureRecognizer提供了當手指在螢幕移動這一過程的描述我們需要一個容器控制元件來對拖拽物進行包裝,以賦予拖拽物響應平移手勢的能力。

建立平移手勢容器控制元件:在Controls目錄中新建PanContainer.xaml,程式碼如下:

<?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"
             x:Class="MatoMusic.Controls.PanContainer">
    <ContentView.GestureRecognizers>
        <PanGestureRecognizer PanUpdated="PanGestureRecognizer_OnPanUpdated"></PanGestureRecognizer>
        <TapGestureRecognizer Tapped="TapGestureRecognizer_OnTapped"></TapGestureRecognizer>

    </ContentView.GestureRecognizers>
</ContentView>

為PanContainer新增PitLayout屬性,用來存放pit的集合。
開啟PanContainer.xaml.cs,新增如下程式碼:


private IList<PitGrid> _pitLayout;

public IList<PitGrid> PitLayout
{
    get { return _pitLayout; }
    set { _pitLayout = value; }
}

CurrentView屬性為當前拖拽物所在的pit控制元件。


private PitGrid _currentView;

public PitGrid CurrentView
{
    get { return _currentView; }
    set { _currentView = value; }
}

新增PositionX和PositionY兩個可繫結屬性,用來設定拖拽物的初始位置。當值改變時,將拖拽物的位置設定為新的值。


public static readonly BindableProperty PositionXProperty =
 BindableProperty.Create("PositionX", typeof(double), typeof(PanContainer), default(double), propertyChanged: (bindable, oldValue, newValue) =>
 {
     var obj = (PanContainer)bindable;
     //obj.Content.TranslationX = obj.PositionX;
     obj.Content.TranslateTo(obj.PositionX, obj.PositionY, 0);

 });

public static readonly BindableProperty PositionYProperty =
BindableProperty.Create("PositionY", typeof(double), typeof(PanContainer), default(double), propertyChanged: (bindable, oldValue, newValue) =>
{
    var obj = (PanContainer)bindable;
    obj.Content.TranslateTo(obj.PositionX, obj.PositionY, 0);
    //obj.Content.TranslationY = obj.PositionY;

});

訂閱PanGestureRecognizer的PanUpdated事件:

 private async void PanGestureRecognizer_OnPanUpdated(object sender, PanUpdatedEventArgs e)
{
    var isInPit = false;
    var isAdsorbInPit = false;

    switch (e.StatusType)
    {
        case GestureStatus.Started: // 手勢啟動
            break;
        case GestureStatus.Running: // 手勢正在執行
            break;
        case GestureStatus.Completed: // 手勢完成
            break;   
    }
}              

接下來我們將對手勢的各狀態:啟動、正在執行、已完成的狀態做處理

手勢開始

  • GestureStatus.Started:手勢開始時觸發, 觸發動畫效果,將拖拽物縮小,同時向訊息訂閱者傳送PanType.Start訊息。
case GestureStatus.Started:
    Content.Scale=0.5;
    WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Start, this.CurrentView), TokenHelper.PanAction);

    break;

手勢執行

GestureStatus.Running:手勢正在執行時觸發,這個狀態下,
根據手指在螢幕上的移動距離來計算translationX和translationY,他們是拖拽物在X和Y方向上的移動距離。
在X軸方向不超過螢幕的左右邊界,即x不得大於this.Width - Content.Width / 2,不得小於 0 - Content.Width / 2

同理
在Y軸方向不超過螢幕的上下邊界,即y不得大於this.Height - Content.Height / 2,不得小於 0 - Content.Height / 2

程式碼如下:

 case GestureStatus.Running:
    var translationX =
        Math.Max(0 - Content.Width / 2, Math.Min(PositionX + e.TotalX, this.Width - Content.Width / 2));
    var translationY =
        Math.Max(0 - Content.Height / 2, Math.Min(PositionY + e.TotalY, this.Height - Content.Height / 2));

接下來判定拖拽物邊界

pit的邊界是通過Region類來描述的,Region類有四個屬性:StartX、EndX、StartY、EndY,分別表示pit的左右邊界和上下邊界。

public class Region
{
    public string Name { get; set; }
    public double StartX { get; set; }
    public double EndX { get; set; }
    public double StartY { get; set; }
    public double EndY { get; set; }
}

對PitLayout中的pit進行遍歷,判斷拖拽物是否在pit內,如果在,則將isInPit設定為true。

判定條件是如果拖拽物的中心位置在pit的邊緣內,則認為拖拽物在pit內。


```csharp
if (PitLayout != null)
{

    foreach (var item in PitLayout)
    {

        var pitRegion = new Region(item.X, item.X + item.Width, item.Y, item.Y + item.Height, item.PitName);
        var isXin = translationX >= pitRegion.StartX - Content.Width / 2 && translationX <= pitRegion.EndX - Content.Width / 2;
        var isYin = translationY >= pitRegion.StartY - Content.Height / 2 && translationY <= pitRegion.EndY - Content.Height / 2;
        if (isYin && isXin)
        {
            isInPit = true;
            if (this.CurrentView == item)
            {
                isSwitch = false;
            }
            else
            {
                if (this.CurrentView != null)
                {
                    isSwitch = true;
                }
                this.CurrentView = item;

            }

        }
    }

}

isSwitch是用於檢測是否跨過pit,當CurrentView非Null改變時,說明拖拽物跨過了緊挨著的兩個pit,需要手動觸發PanType.Out和PanType.In訊息。

IsInPitPre用於記錄在上一次遍歷中是否已經傳送了PanType.In訊息,如果已經傳送,則不再重複傳送。

if (isInPit)
{
    if (isSwitch)
    {
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Out, this.CurrentView), TokenHelper.PanAction);
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.In, this.CurrentView), TokenHelper.PanAction);
        isSwitch = false;
    }
    if (!isInPitPre)
    {
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.In, this.CurrentView), TokenHelper.PanAction);
        isInPitPre = true;


    }
}
else
{
    if (isInPitPre)
    {
        WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Out, this.CurrentView), TokenHelper.PanAction);
        isInPitPre = false;
    }
    this.CurrentView = null;

}

最後,將拖拽物控制元件移動到當前指尖的位置上:

Content.TranslationX = translationX;
Content.TranslationY = translationY;

break;

手勢結束

  • GustureStatus.Completed:手勢結束時觸發,觸發動畫效果,將拖拽物放大,同時回彈至原來的位置,最後向訊息訂閱者傳送PanType.Over訊息。
case GestureStatus.Completed:

    Content.TranslationX= PositionX;
    Content.TranslationY= PositionY;
    Content.Scale= 1;
    WeakReferenceMessenger.Default.Send<PanActionArgs, string>(new PanActionArgs(PanType.Over, this.CurrentView), TokenHelper.PanAction);

    break;

使用控制元件

拖拽物

拖拽物可以是任意控制元件。它將響應手勢。在這裡定義一個圓形的250*250的半通明黑色BoxView,這個抽象的唱盤就是拖拽物。將響應「平移手勢」和「點選手勢」

<BoxView HeightRequest="250"
        WidthRequest="250"
        Margin="7.5"
        Color="#60000000"
        VerticalOptions="CenterAndExpand"
        HorizontalOptions="CenterAndExpand"
        CornerRadius="250" ></BoxView>

建立pit集合

MainPage.xaml中定義一個PitContentLayout,這個AbsoluteLayout型別的容器控制元件,內包含一系列控制元件作為pit,這些pit集合將作為平移手勢容器的判斷依據。

<AbsoluteLayout x:Name="PitContentLayout">
    <--pit控制元件-->
    ...
</AbsoluteLayout>

在頁面載入完成後,將PitContentLayout中的pit集合賦值給平移手勢容器的PitLayout屬性。

private async void MainPage_Appearing(object sender, EventArgs e)
{
    this.DefaultPanContainer.PitLayout=this.PitContentLayout.Children.Select(c => c as PitGrid).ToList();
}

至此我們完成了平移手勢系統的搭建。

這個控制元件可以拓展到任何檢測手指在螢幕上的移動,並可用於將移動應用於內容的用途,例如地圖或者圖片的平移拖拽等。

專案地址

Github:maui-samples