[MAUI]实现动态拖拽排序网格

@

上一章我们使用拖放(drag-drop)手势识别实现了可拖拽排序列表,对于列表中的条目,完整的拖拽排序过程是:
手指触碰条目 -> 拖拽条目 -> 拖拽悬停在另一个条目上方 -> 松开手指 -> 移动条目至此处。

其是在松开手指之后才向列表提交条目位置变更的命令。今天我们换一个写法,将拖拽条目放置在另一个条目上方时,即可将条目位置变更。即实时拖拽排序。

[MAUI]实现动态拖拽排序网格

使用.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>  

呈现效果如下:

[MAUI]实现动态拖拽排序网格

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);         }     }  } 

效果如下:

[MAUI]实现动态拖拽排序网格

释放(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事件,导致错乱。

[MAUI]实现动态拖拽排序网格

在某些机型上甚至会引发错乱的持续循环

一个办法是禁用动画,如在iOS中配置

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

动效问题最终要解决。由于快速拖拽Tile经过较多的位置频繁触发Move操作,通过限制事件的触发频率,引入限流(Throttle)和防抖(Debounce)机制可以有效地解决这个问题。限流和防抖的作用如下图:

[MAUI]实现动态拖拽排序网格

代码引用自 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操作。问题解决!

[MAUI]实现动态拖拽排序网格

因为有500毫秒的延迟,Tile响应上感觉没有那么“灵动”,这算是一种牺牲。在不同的平台上可以调整这个时间以达到一种平衡,不知道屏幕前的你有没有更好的方式解决呢?

[MAUI]实现动态拖拽排序网格

项目地址

Github:maui-samples

发表评论

相关文章