@
上一章我们使用拖放(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响应上感觉没有那么“灵动”,这算是一种牺牲。在不同的平台上可以调整这个时间以达到一种平衡,不知道屏幕前的你有没有更好的方式解决呢?