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

项目地址
[MAUI]实现动态拖拽排序列表的更多相关文章
- jQuery可拖拽排序列表jquery-sortable-lists
jquery-sortable-lists可以通过鼠标进行拖动排列树型菜单,可以定义某个列表元素是否拖动,拖动后回调,点击可以折叠树型结点,可以用来在后台模仿wordpress后台拖动菜单,实现多级菜 ...
- ListView列表拖拽排序
ListView列表拖拽排序能够參考Android源代码下的Music播放列表,他是能够拖拽的,源代码在[packages/apps/Music下的TouchInterceptor.java下]. 首 ...
- odoo开发笔记-tree列表视图拖拽排序
odoo列表tree视图 拖拽排序 实现效果: 实现方式: 模型中定义字段: class CusYourModel(models.Model): """ 你的模型 &qu ...
- jquery拖拽排序,针对后台列表table进行拖拽排序(超实用!)
现在很多后台列表为了方便均使用拖拽排序的功能,对列表进行随意的排序. 话不多说 ,我在网上找了一些demo,经过对比,现在把方便实用的一个demo列出来,基于jqueryUI.js 先上html代码, ...
- vue列表拖拽排序功能实现
1.实现目标:目标是输入一个数组,生成一个列表:通过拖拽排序,拖拽结束后输出一个经过排序的数组. 2.实现思路: 2.1是使用HTML5的drag功能来实现,每次拖拽时直接操作Dom节点排序,拖拽结束 ...
- 基于html5拖拽api实现列表的拖拽排序
基于html5拖拽api实现列表的拖拽排序 html代码: <ul ondrop="drop_handler(event);" ondragover="dragov ...
- html5 Sortable.js 拖拽排序源码分析
最近公司项目经常用到一个拖拽 Sortable.js插件,所以有空的时候看了 Sortable.js 源码,总共1300多行这样,写的挺完美的. 本帖属于原创,转载请出名出处. 官网http:// ...
- dragsort html拖拽排序
一.Jquery List DragSort 对于有些页面,如首页的定制,需要进行动态的拖拽排序.由于自己实现比较困难,我们一般会使用一些js插件来实现.dragsort 就是帮助我们完成这一需求.通 ...
- RecyclerView拖拽排序和滑动删除实现
效果图 如何实现 那么是如何实现的呢?主要就要使用到ItemTouchHelper ,ItemTouchHelper 一个帮助开发人员处理拖拽和滑动删除的实现类,它能够让你非常容易实现侧滑删除.拖拽的 ...
- RecyclerViewItemTouchHelperDemo【使用ItemTouchHelper进行拖拽排序功能】
版权声明:本文为HaiyuKing原创文章,转载请注明出处! 前言 记录使用ItemTouchHelper对Recyclerview进行拖拽排序功能的实现. 效果图 代码分析 ItemTouchHel ...
随机推荐
- 【Python&目标识别】调用百度智能云API实现植被识别
百度智能云于2015年正式对外开放运营,以"云智一体"为核心赋能千行百业,致力于为企业和开发者提供全球领先的人工智能.大数据和云计算服务及易用的开发工具.凭借先进的 ...
- 深入理解注解驱动配置与XML配置的融合与区别
摘要:本文旨在深入探讨Spring框架的注解驱动配置与XML配置,揭示两者之间的相似性与差异. 本文分享自华为云社区<Spring高手之路2--深入理解注解驱动配置与XML配置的融合与区别> ...
- 【IntelliJ】添加javaweb、tomcat语法支持
默认情况下:idea不支持javaweb的语法 但,我们的期望是: 解决方法:配置tomcat如下: (假设你已经配置好了tomcat)接下来: 1.打开[项目结构(快捷键:Ctrl + Shift ...
- 一次Mybaits查询的源码分析
很好奇Mybaits是怎么将xml和mapper对应起来的,用一段比较简单的demo去debug追踪一下源码看看 先用xml配置的方式,看懂了再去看注解的方式是怎么实现的 获取Mapper Mybai ...
- 从零实现俄罗斯方块(c语言+思路分析)
俄罗斯方块 文章说明: 本文大部分参考至俄罗斯方块(C语言实现)_c语言俄罗斯方块_2021dragon的博客-CSDN博客,本人经过修改编辑,改变了文章的一些思路顺序,使得新手便于理解(个人想法). ...
- 如何通过数据warehouse更好地支持团队管理
目录 引言 数据仓库是企业进行数据分析和决策的重要工具之一,能够帮助企业快速.准确地存储.管理和分析海量数据.但是,由于数据仓库系统的复杂性和广泛应用,如何通过数据 warehouse 更好地支持团队 ...
- Jupyter无法运行websocket错误
Juputer无法连接 系websocket连接出问题,浏览器控制台报错 ws 连接异常 WebSocket connection to 'ws://localhost:8888/api/kernel ...
- Dubbo 我手写几行代码,就把通信模式给你解释清楚!
作者:小傅哥 博客:https://bugstack.cn 原文:https://bugstack.cn/md/road-map/road-map.html 沉淀.分享.成长,让自己和他人都能有所收获 ...
- IIS部署的应用无法自动注册到Nacos
问题描述: 自己开发的某系统后台API接入nacos,在IIS上部署无法自动注册到nacos服务列表中.其根本原因是网站处于休眠状态,当某请求访问该网站时,网站被激活,nacos注册成功. 但这块有个 ...
- SDK 接入|游戏语音之“范围语音”接入实践
语音是线上游戏用户的主要交流方式,大多数用户会通过游戏中的内置语音功能与其他玩家沟通,而一些用户在游戏没有内置语音功能的情况下,通过其他语音软件与玩家沟通. 并且,游戏语音在玩家开黑时承担着至关重要的 ...