[MAUI]實現動態拖拽排序網格

林曉lx發表於2023-09-18

@


上一章我們使用拖放(drag-drop)手勢識別實現了可拖拽排序列表,對於列表中的條目,完整的拖拽排序過程是:
手指觸碰條目 -> 拖拽條目 -> 拖拽懸停在另一個條目上方 -> 鬆開手指 -> 移動條目至此處。

其是在鬆開手指之後才向列表提交條目位置變更的命令。今天我們換一個寫法,將拖拽條目放置在另一個條目上方時,即可將條目位置變更。即實時拖拽排序。

在這裡插入圖片描述

使用.NET MAU實現跨平臺支援,本專案可執行於Android、iOS平臺。

建立頁面元素

新建.NET MAUI專案,命名Tile

本章的例項中使用網格佈局的CollectionView控制元件作為Tile的容器。

CollectionView 的其他佈局方式請參考官方文件 指定 CollectionView 佈局

建立GridTilesPage.xaml

在頁面中建立CollectionView,

<CollectionView Grid.Row="1"
                x:Name="MainCollectionView"
                ItemsSource="{Binding TileSegments}">
    <CollectionView.ItemTemplate>
        <DataTemplate>
            <ContentView HeightRequest="110" WidthRequest="110" HorizontalOptions="Center" VerticalOptions="Center">
                <StackLayout>
                    <StackLayout.GestureRecognizers>
                        <DropGestureRecognizer AllowDrop="True"
                                                DragLeaveCommand="{Binding DragLeave}"
                                                DragLeaveCommandParameter="{Binding}"
                                                DragOverCommand="{Binding DraggedOver}"
                                                DragOverCommandParameter="{Binding}"
                                                DropCommand="{Binding Dropped}"
                                                DropCommandParameter="{Binding}" />
                    </StackLayout.GestureRecognizers>

                    <Border x:Name="ContentLayout"
                            StrokeThickness="0"
                            Margin="0">
                        <Grid>
                            <Grid.GestureRecognizers>
                                <DragGestureRecognizer CanDrag="True"
                                                        DragStartingCommand="{Binding Dragged}"
                                                        DragStartingCommandParameter="{Binding}" />
                            </Grid.GestureRecognizers>

                            <controls1:TileSegmentView HeightRequest="100"
                                                        WidthRequest="100"
                                                        Margin="5,5">

                            </controls1:TileSegmentView>
                            <Button CornerRadius="100"
                                    HeightRequest="20"
                                    WidthRequest="20"
                                    Padding="0"
                                    Margin="2,2"
                                    BackgroundColor="Red"
                                    TextColor="White"
                                    Command="{Binding Remove}"
                                    Text="×"
                                    HorizontalOptions="End"
                                    VerticalOptions="Start"></Button>
                        </Grid>
                    </Border>
                </StackLayout>
            </ContentView>

        </DataTemplate>

    </CollectionView.ItemTemplate>
    <CollectionView.ItemsLayout>
        <GridItemsLayout Orientation="Vertical"
                            Span="3" />
    </CollectionView.ItemsLayout>
</CollectionView>

呈現效果如下:

在這裡插入圖片描述

DropGestureRecognizer中設定了拖拽懸停、離開、放置時的命令,

建立IDraggableItem介面, 此處定義拖動相關的屬性和命令。

public interface IDraggableItem
{
    bool IsBeingDraggedOver { get; set; }
    bool IsBeingDragged { get; set; }
    Command Dragged { get; set; }
    Command DraggedOver { get; set; }
    Command DragLeave { get; set; }
    Command Dropped { get; set; }
    object DraggedItem { get; set; }
    object DropPlaceHolderItem { get; set; }
}

Dragged: 拖拽開始時觸發的命令。
DraggedOver: 拖拽控制元件懸停在當前控制元件上方時觸發的命令。
DragLeave: 拖拽控制元件離開當前控制元件時觸發的命令。
Dropped: 拖拽控制元件放置在當前控制元件上方時觸發的命令。

IsBeingDragged 為true時,通知當前控制元件正在被拖拽。
IsBeingDraggedOver 為true時,通知當前控制元件正在有拖拽控制元件懸停在其上方。

DraggedItem: 正在拖拽的控制元件。
DropPlaceHolderItem: 懸停在其上方時的控制元件,即當前控制元件的佔位控制元件。

建立一個TileSegement類,用於描述磁貼可顯示的屬性,如標題、描述、圖示、顏色等。

public class TileSegment 
{
    public string Title { get; set; }
    public string Type { get; set; }
    public string Desc { get; set; }
    public string Icon { get; set; }
    public Color Color { get; set; }
}

建立可繫結物件

建立GridTilesPageViewModel,建立繫結服務類集合TileSegments。

private ObservableCollection<ITileSegmentService> _tileSegments;

public ObservableCollection<ITileSegmentService> TileSegments
{
    get { return _tileSegments; }
    set
    {
        _tileSegments = value;
        OnPropertyChanged();
    }
}

       

建構函式中初始化一些不同顏色的磁貼,並將TileSegementService.Container設定為自己(this)。

public GridTilesPageViewModel()
{
    TileSegments = new ObservableCollection<ITileSegmentService>();
    CreateSegmentAction("TileSegment", "App1", "Some description here", Colors.LightPink);
    CreateSegmentAction("TileSegment", "App2", "Some description here", Colors.LightGreen);

    ...
}
private ITileSegmentService CreateTileSegmentService(object obj, string title, string desc, Color color)
{
    var type = obj as string;
    var tileSegment = new TileSegment()
    {
        Title = title,
        Type = type,
        Desc = desc,
        Icon = "dotnet_bot.svg",
        Color = color,
    };
    var newModel = new GridTileSegmentService(tileSegment); 
    if (newModel != null)
    {
        newModel.Container = this;
    }
    return newModel;
}

建立繫結服務類

建立可拖拽控制元件的繫結服務類GridTileSegmentService,繼承ObservableObject,並實現IDraggableItem介面。

建立ICommand屬性:Dragged, DraggedOver, DragLeave, Dropped。

訂閱PropertyChanged事件以便在屬性更改時觸發相關操作

public class GridTileSegmentService : ObservableObject, ITileSegmentService
{
    public GridTileSegmentService(TileSegment tileSegment)
    {
        TileSegment = tileSegment;
        Dragged = new Command(OnDragged);
        DraggedOver = new Command(OnDraggedOver);
        DragLeave = new Command(OnDragLeave);
        Dropped = new Command(i => OnDropped(i));
        this.PropertyChanged+=GridTileSegmentService_PropertyChanged;
    }
    ...
}

拖拽(Drag)

拖拽開始時,將IsBeingDragged設定為true,通知當前控制元件正在被拖拽,同時將DraggedItem設定為當前控制元件。

private void OnDragged(object item)
{
    IsBeingDragged=true;
    DraggedItem=item;
}

拖拽懸停,經過(DragOver)

拖拽控制元件懸停在當前控制元件上方時,將IsBeingDraggedOver設定為true,通知當前控制元件正在有拖拽控制元件懸停在其上方,同時在服務列表中尋找當前正在被拖拽的服務,將DropPlaceHolderItem設定為當前控制元件。

private void OnDraggedOver(object item)
{
    if (!IsBeingDragged && item!=null)
    {

        var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);
        if (itemToMove.DraggedItem!=null)
        {
            DropPlaceHolderItem=itemToMove.DraggedItem;

        }
        IsBeingDraggedOver=true;

    }
}

離開控制元件上方時,IsBeingDraggedOver設定為false

private void OnDragLeave(object item)
{
    IsBeingDraggedOver = false;
    DropPlaceHolderItem = null;
}

透過訂閱PropertyChanged, 在GridTileSegmentService_PropertyChanged方法中響應IsBeingDraggedOver屬性的值變更。

當IsBeingDraggedOver為True時代表有拖拽中控制元件懸停在其上方,DropPlaceHolderItem即為懸停在其上方的控制元件物件。

此時我們應該將懸停在其上方的控制元件物件插入到自身的前方,透過獲取兩者在集合的角標並呼叫Move()方法。


private void GridTileSegmentService_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName==nameof(this.IsBeingDraggedOver))
    {

        if (this.IsBeingDraggedOver && DropPlaceHolderItem!=null)
        {
            var newIndex = Container.TileSegments.IndexOf(this);
            var oldIndex = Container.TileSegments.IndexOf(DropPlaceHolderItem as ITileSegmentService);
            Container.TileSegments.Move(oldIndex, newIndex);
        }
    }

}

效果如下:

在這裡插入圖片描述

釋放(Drop)

拖拽完成時,獲取當前正在被拖拽的控制元件,將其從服務列表中移除,然後將其插入到當前控制元件的位置,通知當前控制元件拖拽完成。

private void OnDropped(object item)
{
    var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);

    if (itemToMove == null)
        return;

    itemToMove.IsBeingDragged = false;
    IsBeingDraggedOver = false;
    DraggedItem=null;
    DropPlaceHolderItem = null;
}

完整的TileSegmentService程式碼如下:

public class GridTileSegmentService : ObservableObject, ITileSegmentService
{

    public GridTileSegmentService(
        TileSegment tileSegment)
    {
        Remove = new Command(RemoveAction);
        TileSegment = tileSegment;

        Dragged = new Command(OnDragged);
        DraggedOver = new Command(OnDraggedOver);
        DragLeave = new Command(OnDragLeave);
        Dropped = new Command(i => OnDropped(i));
        this.PropertyChanged+=GridTileSegmentService_PropertyChanged;
    }

    private void GridTileSegmentService_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName==nameof(this.IsBeingDraggedOver))
        {

            if (this.IsBeingDraggedOver && DropPlaceHolderItem!=null)
            {
                var newIndex = Container.TileSegments.IndexOf(this);
                var oldIndex = Container.TileSegments.IndexOf(DropPlaceHolderItem as ITileSegmentService);
                Container.TileSegments.Move(oldIndex, newIndex);
            }
        }

    }

    private void OnDragged(object item)
    {
        IsBeingDragged=true;
        DraggedItem=item;


    }

    private void OnDraggedOver(object item)
    {
        if (!IsBeingDragged && item!=null)
        {

            var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);
            if (itemToMove.DraggedItem!=null)
            {
                DropPlaceHolderItem=itemToMove.DraggedItem;

            }
            IsBeingDraggedOver=true;

        }
    }


    private object _draggedItem;

    public object DraggedItem
    {
        get { return _draggedItem; }
        set
        {
            _draggedItem = value;
            OnPropertyChanged();
        }
    }

    private object _dropPlaceHolderItem;

    public object DropPlaceHolderItem
    {
        get { return _dropPlaceHolderItem; }
        set
        {
            _dropPlaceHolderItem = value;
            OnPropertyChanged();
        }
    }

    private void OnDragLeave(object item)
    {
        IsBeingDraggedOver = false;
        DropPlaceHolderItem = null;
    }

    private void OnDropped(object item)
    {
        var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);

        if (itemToMove == null)
            return;


        itemToMove.IsBeingDragged = false;
        IsBeingDraggedOver = false;
        DraggedItem=null;
        DropPlaceHolderItem = null;

    }


    private async void RemoveAction(object obj)
    {
        if (Container is ITileSegmentServiceContainer)
        {
            (Container as ITileSegmentServiceContainer).RemoveSegment.Execute(this);
        }
    }


    public IReadOnlyTileSegmentServiceContainer Container { get; set; }


    private TileSegment tileSegment;

    public TileSegment TileSegment
    {
        get { return tileSegment; }
        set
        {
            tileSegment = value;
            OnPropertyChanged();

        }
    }


    private bool _isBeingDragged;
    public bool IsBeingDragged
    {
        get { return _isBeingDragged; }
        set
        {
            _isBeingDragged = value;
            OnPropertyChanged();

        }
    }

    private bool _isBeingDraggedOver;
    public bool IsBeingDraggedOver
    {
        get { return _isBeingDraggedOver; }
        set
        {
            if (value!=_isBeingDraggedOver)
            {
                _isBeingDraggedOver = value;
                OnPropertyChanged();
            }


        }
    }

    public Command Remove { get; set; }

    public Command Dragged { get; set; }

    public Command DraggedOver { get; set; }

    public Command DragLeave { get; set; }

    public Command Dropped { get; set; }
}

執行程式,此時我們可以看到拖拽控制元件懸停在其它控制元件上方時,其它控制元件會自動調整位置。

限流(Throttle)和防抖(Debounce)

在特定平臺的列表控制元件中更新專案集合時,引發的動畫效果會導致列表中的控制元件位置錯亂。

當以比較快的速度,拖拽Tile經過較多的位置時,後面的Tile會短暫地替代原先的位置,導致拖拽中的Tile不在期望的Tile上方,而拖拽中的Tile與錯誤的Tile產生了交疊從而觸發DraggedOver事件,導致錯亂。

在這裡插入圖片描述

在某些機型上甚至會引發錯亂的持續迴圈

一個辦法是禁用動畫,如在iOS中配置

listView.On<iOS>().SetRowAnimationsEnabled(false);

動效問題最終要解決。由於快速拖拽Tile經過較多的位置頻繁觸發Move操作,透過限制事件的觸發頻率,引入限流(Throttle)和防抖(Debounce)機制可以有效地解決這個問題。限流和防抖的作用如下圖:

在這裡插入圖片描述

程式碼引用自 ThrottleDebounce

在GridTileSegmentService中建立靜態限流器物件變數throttledAction。以及全域性鎖物件throttledLocker。

public static RateLimitedAction throttledAction = Debouncer.Debounce(null, TimeSpan.FromMilliseconds(500), leading: false, trailing: true);

public static object throttledLocker = new object();

改寫GridTileSegmentService_PropertyChanged如下:

private void GridTileSegmentService_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName==nameof(this.IsBeingDraggedOver))
    {

        if (this.IsBeingDraggedOver && DropPlaceHolderItem!=null)
        {
            lock (throttledLocker)
            {
                var newIndex = Container.TileSegments.IndexOf(this);
                var oldIndex = Container.TileSegments.IndexOf(DropPlaceHolderItem as ITileSegmentService);

                var originalAction = () =>
                {
                    Container.TileSegments.Move(oldIndex, newIndex);
                };
                throttledAction.Update(originalAction);
                throttledAction.Invoke();
            }
        }
    }

}

此時,在500毫秒內,只會執行一次Move操作。問題解決!

在這裡插入圖片描述

因為有500毫秒的延遲,Tile響應上感覺沒有那麼“靈動”,這算是一種犧牲。在不同的平臺上可以調整這個時間以達到一種平衡,不知道螢幕前的你有沒有更好的方式解決呢?

在這裡插入圖片描述

專案地址

Github:maui-samples

相關文章