—— 再论为控件动态扩展 DragDrop 能力
夏群林 原创 2025.7.18
一、Drag / Drop 之间传递的参数
前文提到,拖放的实现需要 DragGestureRecognizer 与 DropGestureRecognizer 在不同的控件上相互配合,数据传输和配置复杂。主要有三个事件参数:DragStartingEventArgs,DragEventArgs 和 DropEventArgs。还有一个 DropCompletedEventArgs,不涉及实体数据传递,这里不讨论。
Drag / Drop 操作,本质上是把依存于源控件的数据,与依存于目标控件的数据,挑选出来,组合,供业务流程调用。
DragStartingEventArgs 是数据的起点,它打包了 DataPackage 类型的 Data,以及源控件的位置数据。位置数据暂且不论,我们聚焦在业务层面的实体数据上。去掉枝枝叶叶,在 Maui 中 DragStartingEventArgs 源代码是这样:- public class DragStartingEventArgs : EventArgs
- {
- public bool Handled { get; set; }
- public bool Cancel { get; set; }
- public DataPackage Data { get; } = new DataPackage();
- public virtual Point? GetPosition(Element? relativeTo) =>_getPosition?.Invoke(relativeTo);
- }
复制代码 展开 DataPackage,不神秘,就是一个在应用程序中封装和传递数据的容器,它的只读属性 Properties,一个键值对词典,Dictionary _propertyBag,是载体,用 DataPackagePropertySet 类包装。object 类型的值,你可以在这里放任何你想传递的数据。所以,简单,但强大。- public class DataPackage
- {
- public DataPackagePropertySet Properties { get; }
- public ImageSource Image { get; set; }
- public string Text { get; set; }
-
- public DataPackageView View => new DataPackageView(this.Clone());
- }
- public class DataPackagePropertySet : IEnumerable
- {
- // 这里是数据保持处
- Dictionary<string, object> _propertyBag;
-
- public IEnumerable<string> Keys => _propertyBag.Keys;
- public IEnumerable<object> Values => _propertyBag.Values;
- public void Add(string key, object value)=> _propertyBag.Add(key, value);
- public bool ContainsKey(string key) => _propertyBag.ContainsKey(key);
- public bool TryGetValue(string key, out object value) =>
- _propertyBag.TryGetValue(key, out value);
- }
复制代码 DragEventArgs 是数据中间站,当拖动源控件经停目标控件时,平台会比对两个控件,是否有缘。属于 Drag / Drop 对相同阵营的,- public class DragEventArgs : EventArgs
- {
- public DragEventArgs(DataPackage dataPackage)
- {
- Data = dataPackage;
- }
- public DataPackage Data { get; }
- public DataPackageOperation AcceptedOperation { get; set; } = DataPackageOperation.Copy;
- }
复制代码 就会允许 DataPackage 接收下来打包转发。 AcceptedOperation 决定要不要 Copy。事实上,枚举类型 DataPackageOperation 只有两个值:Copy / None 。同样,我们这里忽略了位置数据的讨论。
最后,DropEventArgs 将 DragStartingEventArgs 传来的 DataPackage 蒙上面纱,以 DataPackageView 面目示人。- public class DropEventArgs
- {
- public DataPackageView Data { get; }
- public bool Handled { get; set; }
- public virtual Point? GetPosition(Element? relativeTo) =>
- _getPosition?.Invoke(relativeTo);
- }
- public class DataPackageView
- {
- public DataPackagePropertySetView Properties { get; }
-
- public Task<ImageSource> GetImageAsync()
- {
- return Task.FromResult(DataPackage.Image);
- }
-
- public Task<string> GetTextAsync()
- {
- return Task.FromResult(DataPackage.Text);
- }
- }
复制代码 DataPackageView 的数据载体是 DataPackagePropertySetView,后者是 DataPackagePropertySet 的只读包装:- public class DataPackagePropertySetView : IReadOnlyDictionary<string, object>
- {
- public DataPackagePropertySet _dataPackagePropertySet;
- public object this[string key] => _dataPackagePropertySet[key];
- public IEnumerable<string> Keys => _dataPackagePropertySet.Keys;
- public IEnumerable<object> Values => _dataPackagePropertySet.Values;
- public int Count => _dataPackagePropertySet.Count;
- public bool ContainsKey(string key) => _dataPackagePropertySet.ContainsKey(key);
- public bool TryGetValue(string key, out object value) => _dataPackagePropertySet.TryGetValue(key, out value);
- }
复制代码 观察 DataPackage / DataPackageView,会发现,除了核心的用户数据字典外,还有一个字符串数据,string Text,一个图像数据,ImageSource Image。拖放操作,在 DragStartingEventArgs 时准备数据。最后在 DropEventArgs 处获取数据:- public Task<ImageSource> GetImageAsync()
- {
- return Task.FromResult(DataPackage.Image);
- }
- public Task<string> GetTextAsync()
- {
- return Task.FromResult(DataPackage.Text);
- }
复制代码 你可以把 Text / Image 看作常用数据快捷通道。我的实践,就是利用这个快捷通道。
二、DataPackagePropertySetView 的核心价值:不止于包装
我相信,初学者大多会有我当初那样的困惑:用 DataPackagePropertySetView 包装 DataPackagePropertySet,是否多此一举?既然底层实质数据一样,用同一个数据类型岂不方便?何必要加 DataPackagePropertySetView 这层皮?
原因是,Maui 为我们带来跨平台数据标准化便利的同时,也带来了跨平台数据传递打包解包的繁杂,以及额外开销。
由于 Maui 控件的实现,最终会转化成应用所在平台的本机实现,Drag / Drop 操作所携带的数据,会一层层转换为本机要求的结构,再一层层转换回 Maui。这里的事情,不简单。
在 MAUI 拖放机制中,DataPackagePropertySetView 绝非简单的字典包装,而是跨平台数据传输的核心枢纽,其设计蕴含三大关键价值:
- 跨平台数据标准化。MAUI需要将数据转换为不同平台的原生格式(如Android的ClipData、iOS的NSItemProvider),而DataPackagePropertySetView通过标准化属性(如Title、Description、Keywords)屏蔽了底层差异。
- 类型安全与延迟加载。强类型访问,避免通过字符串键强制转换类型的风险(如(string)properties["Title"]);仅在调用GetTextAsync()等方法时才实际传输数据,延迟加载,减少无效开销。
- 安全隔离机制。作为只读视图,DataPackagePropertySetView防止拖放目标意外修改源数据,同时通过平台适配器确保数据传输的安全性(如跨进程场景的序列化/反序列化)。
不需要更细节的理解,但是我做了决定,能绕开依赖本机数据转换而传递数据的,最好在 Maui 层面直接处理。这也是当初我开发 AsDroppable/ AsDraggable 扩展方法 (参阅: Maui 实践:为控件动态扩展 DragDrop 能力 )。
自定义数据传递,通常我们会建立全局缓存管理器,在拖放源缓存对象并传递 ID,然后在拖放目标通过 ID 获取对象。因为数据产生于拖放源,如果拖放源不再被引用,其所产生的数据也应该销毁,否则会造成内存泄漏。
问题在于,我们使用 AsDraggable 方法为拖放源配置拖放数据时,不知道该拖放源在程序逻辑中,是否要释放,何时会释放。于是想到用弱引用管理器避免内存泄漏。
ConditionalWeakTable 是 .NET 框架中的一个特殊集合类,在两个对象之间建立弱关联关系,同时确保不会阻止垃圾回收(GC)对这些对象的回收。当 TKey 类型的对象(键)被垃圾回收时,对应的 TValue 类型的值也会被自动从表中移除,不会因为键值对的存在而延长对象的生命周期。这样,我们可以缓存与特定拖 / 放源关联的数据,同时不影响这些对象的垃圾回收。完美。
不过,针对我的应用情形,完美之中有瑕疵。我们的全局缓存管理器在拖放源缓存对象并传递 ID,这个 ID,我直接选用 Guid 类型,可以唯一性区别无限个数据,简洁。但 Guid 是值类型,不符合 ConditionalWeakTable 对 TKey 为引用类型的要求。
我设计了一个包装类,把 Guid 包装成引用类:- public sealed class GuidToken
- {
- public Guid Id { get; } = Guid.NewGuid();
- public string Token => Id.ToString();
- }
复制代码 然后用 GuidToken 作为键,欺骗 ConditionalWeakTable:- private static readonly ConditionalWeakTable<GestureRecognizer, GuidToken> guidTokens = [];
- private static readonly ConditionalWeakTable<GuidToken, DragDropPayload> dragDropPayloads = [];
复制代码 这里,拖 / 放源为键关联 GuidToken 值,再以 GuidToken 为键关联数据 DragDropPayload。这样,我们就实现了在拖放过程中,当平台与本机做着复杂的交互时,只需传递简单的 guid 字符串,还保证不会内存泄漏。
我们还要做点额外的工作:为 GuidToken 建立一个生命期可控的强引用:- private static readonly ConcurrentDictionary<string, WeakReference<GuidToken>> tokenCache = new();
复制代码 否则,GuidToken 不知何时会被 GC,其代表的数据亦不知何时被 GC。手动控制 GuidToken 生命期的方式,结合在 AsDraggable / AsDroppable 扩展方法中,后面一并讲到。
三、进阶:DynamicGesturesExtension 改进
根据前面的讨论,我对自己先前开发的 AsDraggable / AsDroppable 扩展方法予以改进。AsDraggable / AsDroppable 是通用方法,本想通过泛型的方式,源控件类型/目标控件类型的组合,来区分应该采取的拖放后续操作。为此,还回避不了头疼的反射技术。这次我顺手把它去掉了,区分拖放后续操作,只需要通过拖/放控件关联的数据类型 DragDropPayload 的组合,即可确认。我也简化了 DragDropPayload 数据结构,消除协变和逆变的顾忌。- public class DragDropPayload
- {
- public required View View { get; init; } // 拖放源/目标控件
- public object? Affix { get; init; } // 任意附加数据(如文本、对象)
- public Action<View, object?>? Callback { get; init; } // 拖放完成后的回调
- public View? Anchor { get; set; } = null; // 拖放源/目标控件的 recognizer 依附 View 组件
- public SourceTypeEnum SourceType { get; set; } // 标识。源/目标之间标识有交集者才能交互
- }
复制代码 1. 注册数据
- private static string RegisterPayload(this GestureRecognizer recognizer, DragDropPayload payload)
- {
- ArgumentNullException.ThrowIfNull(recognizer);
- ArgumentNullException.ThrowIfNull(payload);
- var guidToken = guidTokens.GetOrCreateValue(recognizer);
- dragDropPayloads.AddOrUpdate(guidToken, payload);
- tokenCache[guidToken.Token] = new WeakReference<GuidToken>(guidToken);
- return guidToken.Token;
- }
复制代码 注册数据在指定源控件 AsDragble 时完成:- public static void AsDraggable<TSourceAnchor, TSource>(this TSourceAnchor anchor, TSource source, Func<TSourceAnchor, TSource, DragDropPayload> payloadCreator)
- where TSourceAnchor : View
- where TSource : View
- {
- AttachDragGestureRecognizer(anchor, source, payloadCreator); // 覆盖现有 payload(如果存在)
- }
- private static void AttachDragGestureRecognizer<TSourceAnchor, TSource>(TSourceAnchor anchor, TSource source, Func<TSourceAnchor, TSource, DragDropPayload> payloadCreator)
- where TSourceAnchor : View
- where TSource : View
- {
- anchor.Undraggable();
- DragGestureRecognizer dragGesture = new() { CanDrag = true };
- anchor.GestureRecognizers.Add(dragGesture);
- dragGesture.DragStarting += (sender, args) =>
- {
- DragDropPayload dragPayload = payloadCreator(anchor, source);
- _ = dragGesture.RegisterPayload(dragPayload);
- args.Data.Text = guidTokens.GetOrCreateValue(dragGesture).Token;
- anchor.Opacity = 0.5;
- };
- dragGesture.DropCompleted += (sender, args) =>
- {
- guidTokens.GetOrCreateValue(dragGesture).Token.RemovePayload();
- };
- }
复制代码 2. 匹配数据,在 DragLeave 事件中处理
- dropGesture.DragOver += (sender, e) =>
- {
- string token = e.Data.Text;
- if (token.TryAssociatedPayload(out DragDropPayload? dragPayload) &&
- guidTokens.TryGetValue(dropGesture, out GuidToken? dropToken) && dropToken is not null &&
- dropToken.Token.TryAssociatedPayload(out DragDropPayload? dropPayload) &&
- (dragPayload.SourceType & dropPayload.SourceType) != 0)
- {
- e.AcceptedOperation = DataPackageOperation.Copy;
- }
- else
- {
- e.AcceptedOperation = DataPackageOperation.None;
- }
- };
- public static bool TryAssociatedPayload(this string token, [NotNullWhen(true)] out DragDropPayload? payload)
- {
- payload = null;
- if (!token.IsValidGuid())
- {
- return false;
- }
- if (tokenCache.TryGetValue(token, out var weakGuidToken) &&
- weakGuidToken.TryGetTarget(out var guidToken) &&
- dragDropPayloads.TryGetValue(guidToken, out payload))
- {
- return true;
- }
- _ = tokenCache.TryRemove(token, out _); // 尝试清理缓存
- return false;
- }
复制代码 注意上面尝试清理缓存,顺手做的。在DropCompleted事件处理中,此时数据传递使命完成,会专门移除缓存数据:- dragGesture.DropCompleted += (sender, args) =>
- {
- guidTokens.GetOrCreateValue(dragGesture).Token.RemovePayload();
- };
- public static void RemovePayload(this string token)
- {
- if (!token.IsValidGuid() || !tokenCache.TryGetValue(token, out var weakToken))
- {
- return;
- }
- if (weakToken.TryGetTarget(out var guidToken))
- {
- _ = dragDropPayloads.Remove(guidToken);
- }
- _ = tokenCache.TryRemove(token, out _);
- }
复制代码 3. 发送数据组合,OnDroppablesMessageAsync
- public static void AsDroppable<TTargetAnchor, TTarget>(this TTargetAnchor anchor, DragDropPayload payload)
- where TTargetAnchor : View
- where TTarget : View
- {
- anchor.Undroppable();
- DropGestureRecognizer dropGesture = new() { AllowDrop = true };
- anchor.GestureRecognizers.Add(dropGesture);
- _ = dropGesture.RegisterPayload(payload);
- // ... ...
- dropGesture.Drop += async (s, e) =>
- {
- await OnDroppablesMessageAsync<TTargetAnchor>(anchor, dropGesture, e);
- // ... ...
- };
- }
- private static async Task OnDroppablesMessageAsync<TTargetAnchor>(TTargetAnchor anchor, DropGestureRecognizer dropGesture, DropEventArgs e)
- where TTargetAnchor : View
- {
- string token = await e.Data.GetTextAsync();
- // ... ...
- _ = WeakReferenceMessenger.Default.Send<DragDropMessage>(new DragDropMessage()
- {
- SourcePayload = sourcePayload,
- TargetPayload = targetPayload
- });
- // ... ...
- }
复制代码 五、总结:从"数据传输"到"对象生命周期管理"
MAUI拖放功能的核心挑战不仅在于数据传递,更在于对象生命周期的安全管理。DataPackagePropertySetView通过标准化接口屏蔽了平台差异,而ConditionalWeakTable则解决了复杂对象传输的性能与内存问题。
本 DynamicGesturesExtension 改进方案已在实际项目中验证,源代码开源,按照 MIT 协议许可。地址:xiaql/Zhally.Toolkit: Dynamically attach draggable and droppable capability to controls of View in MAUI
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |