------ 再论为控件动态扩展 DragDrop 能力
夏群林 原创 2025.7.18
一、Drag / Drop 之间传递的参数
前文提到,拖放的实现需要 DragGestureRecognizer 与 DropGestureRecognizer 在不同的控件上相互配合,数据传输和配置复杂。主要有三个事件参数:DragStartingEventArgs,DragEventArgs 和 DropEventArgs。还有一个 DropCompletedEventArgs,不涉及实体数据传递,这里不讨论。
Drag / Drop 操作,本质上是把依存于源控件的数据,与依存于目标控件的数据,挑选出来,组合,供业务流程调用。
DragStartingEventArgs 是数据的起点,它打包了 DataPackage 类型的 Data,以及源控件的位置数据。位置数据暂且不论,我们聚焦在业务层面的实体数据上。去掉枝枝叶叶,在 Maui 中 DragStartingEventArgs 源代码是这样:
c#
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<string, object> _propertyBag,是载体,用 DataPackagePropertySet 类包装。object 类型的值,你可以在这里放任何你想传递的数据。所以,简单,但强大。
c#
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 对相同阵营的,
c#
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 面目示人。
C#
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 的只读包装:
c#
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 处获取数据:
c#
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<TKey, TValue> 是 .NET 框架中的一个特殊集合类,在两个对象之间建立弱关联关系,同时确保不会阻止垃圾回收(GC)对这些对象的回收。当 TKey
类型的对象(键)被垃圾回收时,对应的 TValue
类型的值也会被自动从表中移除,不会因为键值对的存在而延长对象的生命周期。这样,我们可以缓存与特定拖 / 放源关联的数据,同时不影响这些对象的垃圾回收。完美。
不过,针对我的应用情形,完美之中有瑕疵。我们的全局缓存管理器在拖放源缓存对象并传递 ID,这个 ID,我直接选用 Guid 类型,可以唯一性区别无限个数据,简洁。但 Guid 是值类型,不符合 ConditionalWeakTable<TKey, TValue> 对 TKey 为引用类型的要求。
我设计了一个包装类,把 Guid 包装成引用类:
c#
public sealed class GuidToken
{
public Guid Id { get; } = Guid.NewGuid();
public string Token => Id.ToString();
}
然后用 GuidToken 作为键,欺骗 ConditionalWeakTable:
c#
private static readonly ConditionalWeakTable<GestureRecognizer, GuidToken> guidTokens = [];
private static readonly ConditionalWeakTable<GuidToken, DragDropPayload> dragDropPayloads = [];
这里,拖 / 放源为键关联 GuidToken 值,再以 GuidToken 为键关联数据 DragDropPayload。这样,我们就实现了在拖放过程中,当平台与本机做着复杂的交互时,只需传递简单的 guid 字符串,还保证不会内存泄漏。
我们还要做点额外的工作:为 GuidToken 建立一个生命期可控的强引用:
c#
private static readonly ConcurrentDictionary<string, WeakReference<GuidToken>> tokenCache = new();
否则,GuidToken 不知何时会被 GC,其代表的数据亦不知何时被 GC。手动控制 GuidToken 生命期的方式,结合在 AsDraggable / AsDroppable 扩展方法中,后面一并讲到。
三、进阶:DynamicGesturesExtension 改进
根据前面的讨论,我对自己先前开发的 AsDraggable / AsDroppable 扩展方法予以改进。AsDraggable / AsDroppable 是通用方法,本想通过泛型的方式,源控件类型/目标控件类型的组合,来区分应该采取的拖放后续操作。为此,还回避不了头疼的反射技术。这次我顺手把它去掉了,区分拖放后续操作,只需要通过拖/放控件关联的数据类型 DragDropPayload 的组合,即可确认。我也简化了 DragDropPayload 数据结构,消除协变和逆变的顾忌。
c#
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. 注册数据
c#
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 时完成:
c#
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 事件中处理
c#
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事件处理中,此时数据传递使命完成,会专门移除缓存数据:
c#
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
c#
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