作者:夏群林 原创 2025.6.9
拖放的实现,和其他的 GestureRecognizer 不同,需要 DragGestureRecognizer 与 DropGestureRecognizer 相互配合,Drag / Drop 又是在不同的控件上附加的,数据传输和配置相对复杂,不太好理解。需要彻底阅读源代码,才能真的把握。我做了一个扩展方法,把复杂的配置包裹起来,在代码层面与要附加拖放功能的控件分离,用户只需关注拖放动作所支持的业务功能即可。
直接上代码。
一、核心架构与关键组件
1. 数据载体:DragDropPayload
解耦控件与业务逻辑,封装拖放所需的视图引用、附加数据和回调逻辑。- public interface IDragDropPayload
- {
- public View View { get; } // 拖放源/目标控件
- public object? Affix { get; } // 任意附加数据(如文本、对象)
- public Action? Callback { get; } // 拖放完成后的回调
- }
- public class DragDropPayload<TView> : IDragDropPayload where TView : View
- {
- public required TView View { get; init; }
- public object? Affix { get; init; }
- public Action? Callback { get; init; }
- View IDragDropPayload.View => View;
- }
复制代码 关键点:
- View:强类型视图引用,确保拖放操作与具体控件绑定。
- Affix:支持传递复杂数据,用于拖和放时,对源控件和目标控件进行处理所需附加的数据。 默认为 null。
- Callback:用于执行拖放后的轻量化操作(如日志记录、UI 微更新),对源控件和目标控件分别处理。可得到 Affix 数据支持。默认为 null。即不处理。
- 设计 IDragDropPayload 公共接口,配置协变,是本扩展方法保持精干而又多面的关键。
2. 消息传递:DragDropMessage
通过泛型消息明确拖放类型,实现跨层业务逻辑解耦。 这里也配置了协变,便于 WeakReferenceMessenger 引用。使用反射权衡后的妥协。- public interface IDragDropMessage
- {
- public IDragDropPayload SourcePayload { get; }
- public IDragDropPayload TargetPayload { get; }
- }
- public sealed class DragDropMessage<TSource, TTarget> : IDragDropMessage
- where TSource : View
- where TTarget : View
- {
- public required DragDropPayload<TSource> SourcePayload { get; init; }
- public required DragDropPayload<TTarget> TargetPayload { get; init; }
- IDragDropPayload IDragDropMessage.SourcePayload => SourcePayload;
- IDragDropPayload IDragDropMessage.TargetPayload => TargetPayload;
- }
复制代码 关键点:
- 类型安全:通过 TSource 和 TTarget 约束拖放的源/目标类型(如 Label→Border)。
- 数据透传:通过 DataPackagePropertySet 传递扩展属性,避免消息类字段膨胀。
- 解耦业务:消息仅负责数据传递,具体逻辑由订阅者(如 MainPage)处理。
3. AsDraggable 扩展方法
通过扩展方法为任意控件注入拖放能力,屏蔽手势识别细节。- public static void AsDraggable<TSource>(this TSource source, object? sourceAffix = null, Action? sourceCallback = null)
- where TSource : View
- {
- // 创建并存储 payload
- var payload = new DragDropPayload<TSource>
- {
- View = source,
- Affix = sourceAffix,
- Callback = sourceCallback
- };
- // 覆盖现有 payload(如果存在)
- dragPayloads.AddOrUpdate(source, payload);
- // 查找或创建 DragGestureRecognizer
- var dragGesture = source.GestureRecognizers.OfType<DragGestureRecognizer>().FirstOrDefault();
- if (dragGesture == null)
- {
- dragGesture = new DragGestureRecognizer { CanDrag = true };
- source.GestureRecognizers.Add(dragGesture);
- // 只在首次添加手势时注册事件
- dragGesture.DragStarting += (sender, args) =>
- {
- // 通过 dragPayloads 提取最新的 payload
- if (dragPayloads.TryGetValue(source, out var dragPayload) && dragPayload is DragDropPayload<TSource> payload)
- {
- args.Data.Properties.Add("SourcePayload", payload);
- source.Opacity = 0.5;
- }
- };
- }
- }
复制代码 4. AsDroppable 扩展方法
- public static void AsDroppable<TTarget>(this TTarget target, object? targetAffix = null, Action? targetCallback = null)
- where TTarget : View
- {
- AsDroppable<View, TTarget>(target, targetAffix, targetCallback);
- }
- public static void AsDroppable<TSource, TTarget>(this TTarget target, object? targetAffix = null, Action? targetCallback = null)
- where TSource : View
- where TTarget : View
- {
- var dropGesture = target.GestureRecognizers.OfType<DropGestureRecognizer>().FirstOrDefault();
- if (dropGesture is null)
- {
- dropGesture = new DropGestureRecognizer() { AllowDrop = true };
- target.GestureRecognizers.Add(dropGesture);
- DragDropPayload<TTarget> defaultPayload = new()
- {
- View = target,
- Affix = null,
- Callback = null
- };
- _ = dropPayloads
- .GetOrCreateValue(dropGesture)
- .GetOrAdd(typeof(View).Name, _ => defaultPayload);
- dropGesture.DragOver += (sender, args) =>
- {
- bool isSupported = args.Data.Properties.TryGetValue("SourcePayload", out _);
- target.BackgroundColor = isSupported ? Colors.LightGreen : Colors.Transparent;
- };
- dropGesture.DragLeave += (sender, args) =>
- {
- target.BackgroundColor = Colors.Transparent;
- };
- dropGesture.Drop += (s, e) => OnDroppablesMessage<TTarget>(target, dropGesture, e);
- }
- DragDropPayload<TTarget> sourceSpecificDropPayload = new()
- {
- View = target,
- Affix = targetAffix,
- Callback = targetCallback
- };
- var payloadDict = dropPayloads.GetOrCreateValue(dropGesture);
- _ = payloadDict.AddOrUpdate(typeof(TSource).Name, (s) => sourceSpecificDropPayload, (s, old) => sourceSpecificDropPayload);
- }
复制代码 核心机制:
- 手势识别器:使用 DragGestureRecognizer 和 DropGestureRecognizer 捕获拖放事件。 保持实例唯一。
- 类型映射表:静态存储器 dragPayloads / dropPayloads 存储可支持的拖、放对象及其附加的数据,保持最新。
- 消息注册:为每种类型组合注册唯一的消息处理函数,确保消息精准投递。
- 方法重载:AsDroppable ,无特殊数据和动作附加的,可简化处理,毋须逐一注册类型配对。
二、关键实现细节
1. ConditionalWeakTable
在 DragDropExtensions 中,我们使用两个 ConditionalWeakTable 实现状态管理,保证拖放事件发生时传递最新约定的数据。
ConditionalWeakTable 最大的好处是避免内存泄漏。用 View 或 GestureRecognizer 实例作为键,当该实例不再被别处引用时,内存回收机制会自动清除对应的键值对,无需用户专门释放内存。- private static readonly ConditionalWeakTable<View, IDragDropPayload> dragPayloads = [];
- private static readonly ConditionalWeakTable<GestureRecognizer, ConcurrentDictionary<string, IDragDropPayload>> dropPayloads = [];
复制代码 2. dropPayloads
为每个 DropGestureRecognizer 关联源类型映射和对应该源类型所预先配置目标类型 TargetPayload。- DragDropPayload<TTarget> sourceSpecificDropPayload = new()
- {
- View = target,
- Affix = targetAffix,
- Callback = targetCallback
- };
- var payloadDict = dropPayloads.GetOrCreateValue(dropGesture);
- _ = payloadDict.AddOrUpdate(typeof(TSource).Name, (s) => sourceSpecificDropPayload, (s, old) => sourceSpecificDropPayload);
复制代码 还贴心地预备好默认配置:- DragDropPayload<TTarget> defaultPayload = new()
- {
- View = target,
- Affix = null,
- Callback = null
- };
- _ = dropPayloads
- .GetOrCreateValue(dropGesture)
- .GetOrAdd(typeof(View).Name, _ => defaultPayload);
复制代码 3 . dragPayloads
源类型 SourcePayload 配置表,在 DragGestureRecognizer 首次配置时注册,重复 AsDraggable 方法时更新。- // 创建并存储 payload
- var payload = new DragDropPayload<TSource>
- {
- View = source,
- Affix = sourceAffix,
- Callback = sourceCallback
- };
- // 覆盖现有 payload(如果存在)
- dragPayloads.AddOrUpdate(source, payload);
复制代码 4 . IDragDropMessage / WeakReferenceMessenger
反射获取分类拖放消息,但需要统一发送:- // 构建泛型类型
- Type genericMessageType = typeof(DragDropMessage<,>);
- Type constructedMessageType = genericMessageType.MakeGenericType(sourceType, typeof(TTarget));
- // 创建实例
- object? message = Activator.CreateInstance(constructedMessageType);
- if (message is null)
- {
- return;
- }
- // 设置属性
- PropertyInfo sourceProp = constructedMessageType.GetProperty("SourcePayload")!;
- PropertyInfo targetProp = constructedMessageType.GetProperty("TargetPayload")!;
- sourceProp.SetValue(message, sourcePayload);
- targetProp.SetValue(message, targetPayload);
- // 核心动作
- _ = WeakReferenceMessenger.Default.Send<IDragDropMessage>((IDragDropMessage)message);
复制代码 三、 反射的优化
尝试了很多办法,还是采用反射技术,最为直接。
我并不喜欢使用反射。消耗大不说,现在 Microsoft 大力推进 Native AOT( Ahead Of Time)编译,将.NET 代码提前编译为本机代码,对反射的使用有约束,如果代码中反射模式导致 AOT 编译器无法静态分析,就会产生裁剪警告,甚至可能导致编译失败或运行时异常。
因此,在 .NET MAUI 的 AOT 编译环境下,对反射泛型类型的创建需要特殊处理。这里通过 预编译委托缓存 + 静态类型注册 的组合方案,实现了AOT 的泛型消息工厂。高效是肯定的,目前看来,是兼容的。
使用 ConcurrentDictionary 存储注册的源类型和目标类型,通过 "Source" 和 "Target" 两个键区分不同角色的类型集合, HashSet 确保类型唯一性,避免重复注册。- private static readonly ConcurrentDictionary<string, HashSet<Type>> registeredTypes = new();
复制代码 自动配对机制:当新类型注册时,自动与已注册的对立类型(源→目标,目标→源)创建所有可能的配对组合(静态),确保 AOT 环境下反射可用。- private static void RegisterType(string role, Type type)
- {
- // 获取或创建对应角色的类型集合
- var types = registeredTypes.GetOrAdd(role, _ => []);
- // 添加类型并判断是否为新增(返回true表示新增)
- if (types.Add(type))
- {
- // 新注册的类型,补全所有可能的配对组合
- if (role == "Source")
- {
- // 源类型:与所有已注册的目标类型配对
- if (registeredTypes.TryGetValue("Target", out var targetTypes))
- {
- foreach (var targetType in targetTypes)
- {
- RegisterMessageFactory(type, targetType);
- }
- }
- }
- else if (role == "Target")
- {
- // 目标类型:与所有已注册的源类型配对
- if (registeredTypes.TryGetValue("Source", out var sourceTypes))
- {
- foreach (var sourceType in sourceTypes)
- {
- RegisterMessageFactory(sourceType, type);
- }
- }
- }
- }
- }
复制代码 反射泛型工厂:每个类型组合仅反射一次,生成的委托被缓存- private static readonly ConcurrentDictionary<(Type source, Type target), Func<IDragDropPayload, IDragDropPayload, IDragDropMessage>> messageFactories = new();
- private static void RegisterMessageFactory(Type sourceType, Type targetType)
- {
- var key = (sourceType, targetType);
- messageFactories.GetOrAdd(key, _ => {
- // 仅首次执行反射
- var messageType = typeof(DragDropMessage<,>).MakeGenericType(sourceType, targetType);
- return (sourcePayload, targetPayload) => {
- var message = Activator.CreateInstance(messageType)!;
- // 设置属性...
- return (IDragDropMessage)message;
- };
- });
- }
复制代码 反射优化策略:后续调用直接执行委托,避免重复反射- // 通过预注册的工厂创建消息实例
- var key = (sourceType, typeof(TTarget));
- if (messageFactories.TryGetValue(key, out var factory))
- {
- var message = factory(sourcePayload, targetPayload);
- // 核心动作
- _ = WeakReferenceMessenger.Default.Send<IDragDropMessage>(message);
- }
复制代码 AOT 兼容性保障
预编译委托缓存方案,支持任意类型组合,仅首次注册时有反射开销,平衡灵活性和性能,但需要在编译前静态注册所有可能的类型组合,避免运行时动态生成未知类型组合。
必要的话,可使用 [assembly: Preserve] 属性保留泛型类型及其成员。暂时没采用这种方法,寄希望于 Microsoft 自行保证兼容性。
四、使用示例
MainPage.xaml
- <?xml version="1.0" encoding="utf-8" ?>
- <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
- xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
- x:
- Title="拖放示例">
- <StackLayout Spacing="20" Padding="30">
- <Label Text="高级拖放示例"
- FontSize="22"
- FontAttributes="Bold"
- HorizontalOptions="Center" />
- <HorizontalStackLayout
- HorizontalOptions="Center">
- <Label x:Name="DragLabel"
- Text="拖放示例文本"
- BackgroundColor="LightBlue"
- Padding="12"
- HorizontalOptions="Center"
- FontSize="16" />
- <BoxView x:Name="DragBoxView"
- HeightRequest="60"
- WidthRequest="120"
- BackgroundColor="LightPink"
- HorizontalOptions="Center" />
- <ContentView x:Name="DragContentView"
- HeightRequest="60"
- WidthRequest="120"
- BackgroundColor="LightCyan"
- HorizontalOptions="Center" />
- </HorizontalStackLayout>
- <Border x:Name="DropBorder"
- BackgroundColor="LightGreen"
- Padding="20"
- Margin="10"
- HorizontalOptions="Center"
- WidthRequest="200"
- HeightRequest="100">
- <Label Text="放置目标区域" HorizontalOptions="Center" />
- </Border>
- <Label x:Name="ResultLabel"
- Text="等待拖放操作..."
- HorizontalOptions="Center"
- FontAttributes="Italic"
- TextColor="Gray" />
- </StackLayout>
- </ContentPage>
复制代码 MainPage.xaml.cs
- using CommunityToolkit.Mvvm.Messaging;
- using System.Diagnostics;
- using Zhally.DragDrop.Controls;
- namespace Zhally.DragDrop;
- public partial class MainPage : ContentPage
- {
- public MainPage()
- {
- InitializeComponent();
- SetupDragDrop();
- }
- private void SetupDragDrop()
- {
- // 设置可拖动元素(携带 Payload 数据)
- DragLabel.AsDraggable<Label>(
- sourceAffix: new { Type = "文本数据", Value = "Hello World" },
- sourceCallback: () => Debug.WriteLine("拖动源回调")
- );
- DragLabel.AsDraggable<Label>(
- sourceAffix: new { Type = "文本数据", Value = "Hello World agian" },
- sourceCallback: () => Debug.WriteLine("拖动源回调 again")
- );
- DragBoxView.AsDraggable<BoxView>(
- sourceAffix: new { Type = "BoxView数据", Value = "BoxView" },
- sourceCallback: () => Debug.WriteLine("按钮拖动回调")
- );
- DragContentView.AsDraggable<ContentView>(
- sourceAffix: new { Type = "ContentView数据", Value = "ContentView" },
- sourceCallback: () => Debug.WriteLine("按钮拖动回调")
- );
- // 设置可放置元素(携带目标数据)
- DropBorder.AsDroppable<Label, Border>(
- targetAffix: new { Type = "目标数据", Value = "Label Drop Zone" },
- targetCallback: () => Debug.WriteLine("放置目标回调")
- );
- DropBorder.AsDroppable<BoxView, Border>(
- targetAffix: new { Type = "目标数据", Value = "BoxView Drop Zone" },
- targetCallback: () => Debug.WriteLine("放置目标回调")
- );
- // 设置可放置元素(通用,非必须,在携带目标数据时有用)
- DropBorder.AsDroppable<Border>(
- targetAffix: new { Type = "目标数据", Value = "Generic Drop Zone" },
- targetCallback: () => Debug.WriteLine("放置目标回调")
- );
- }
- protected override void OnAppearing()
- {
- base.OnAppearing();
- WeakReferenceMessenger.Default.Register<IDragDropMessage>(this, HandleBorderDragDropMessage);
- }
- protected override void OnDisappearing()
- {
- base.OnDisappearing();
- WeakReferenceMessenger.Default.UnregisterAll(this);
- }
- private void HandleBorderDragDropMessage(object recipient, IDragDropMessage message)
- {
- if (message.SourcePayload.View == null || message.TargetPayload.View == null)
- {
- return;
- }
- switch (message.SourcePayload.View)
- {
- case Label label:
- HandleLabelDrop(label, message);
- break;
- case BoxView boxView:
- HandleBoxViewDrop(boxView, message);
- break;
- case ContentView contentView:
- HandleContentViewDrop(contentView, message);
- break;
- default:
- HandleDefaultDrop(message);
- break;
- }
- }
- private void HandleDefaultDrop(IDragDropMessage message) => HandleBorderMessage(message);
- private void HandleLabelDrop(Label label, IDragDropMessage message) => HandleBorderMessage(message);
- private void HandleBoxViewDrop(BoxView boxView, IDragDropMessage message) => HandleBorderMessage(message);
- private void HandleContentViewDrop(ContentView contentView, IDragDropMessage message) => HandleBorderMessage(message);
- private void HandleBorderMessage(IDragDropMessage message)
- {
- MainThread.BeginInvokeOnMainThread(() =>
- {
- ResultLabel.Text = $"拖放成功!\n" +
- $"源类型: {message.SourcePayload.View.GetType()}\n" +
- $"源数据: {message.SourcePayload.Affix}\n" +
- $"目标数据: {message.TargetPayload.Affix}";
- });
- // 执行回调
- MainThread.BeginInvokeOnMainThread(() =>
- {
- message.SourcePayload.Callback?.Invoke(); // 执行源回调
- });
- // 执行回调
- MainThread.BeginInvokeOnMainThread(() =>
- {
- message.TargetPayload.Callback?.Invoke(); // 执行目标回调
- });
- }
- }
复制代码 五、总结
本方案实现了 MAUI 控件拖放能力的动态扩展。核心设计遵循以下原则:
- 解耦:拖放逻辑与控件分离,通过消息系统连接业务层。
- 类型安全:泛型约束确保拖放类型匹配,编译期暴露潜在问题。
- 可扩展:通过字典映射和消息订阅,轻松支持新的拖放类型组合。
此方案已在实际项目中验证,适用于文件管理、列表排序、数据可视化等场景,为 MAUI 应用提供了灵活高效的拖放解决方案。
本方案源代码开源,按照 MIT 协议许可。地址:xiaql/Zhally.Toolkit: Dynamically attach draggable and droppable capability to controls of View in MAUI
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |