找回密码
 立即注册
首页 业界区 业界 Maui 实践:为控件动态扩展 DragDrop 能力

Maui 实践:为控件动态扩展 DragDrop 能力

赏听然 2025-6-10 12:24:33
作者:夏群林 原创 2025.6.9
拖放的实现,和其他的 GestureRecognizer 不同,需要 DragGestureRecognizer 与 DropGestureRecognizer 相互配合,Drag / Drop 又是在不同的控件上附加的,数据传输和配置相对复杂,不太好理解。需要彻底阅读源代码,才能真的把握。我做了一个扩展方法,把复杂的配置包裹起来,在代码层面与要附加拖放功能的控件分离,用户只需关注拖放动作所支持的业务功能即可。
直接上代码。
一、核心架构与关键组件

1. 数据载体:DragDropPayload

解耦控件与业务逻辑,封装拖放所需的视图引用、附加数据和回调逻辑。
  1. public interface IDragDropPayload
  2. {
  3.     public View View { get; }                   // 拖放源/目标控件
  4.     public object? Affix { get; }               // 任意附加数据(如文本、对象)
  5.     public Action? Callback { get; }            // 拖放完成后的回调
  6. }
  7. public class DragDropPayload<TView> : IDragDropPayload where TView : View
  8. {
  9.     public required TView View { get; init; }
  10.     public object? Affix { get; init; }
  11.     public Action? Callback { get; init; }
  12.     View IDragDropPayload.View => View;
  13. }
复制代码
关键点

  • View:强类型视图引用,确保拖放操作与具体控件绑定。
  • Affix:支持传递复杂数据,用于拖和放时,对源控件和目标控件进行处理所需附加的数据。 默认为 null。
  • Callback:用于执行拖放后的轻量化操作(如日志记录、UI 微更新),对源控件和目标控件分别处理。可得到 Affix 数据支持。默认为 null。即不处理。
  • 设计 IDragDropPayload 公共接口,配置协变,是本扩展方法保持精干而又多面的关键。
2. 消息传递:DragDropMessage

通过泛型消息明确拖放类型,实现跨层业务逻辑解耦。 这里也配置了协变,便于 WeakReferenceMessenger 引用。使用反射权衡后的妥协。
  1. public interface IDragDropMessage
  2. {
  3.     public IDragDropPayload SourcePayload { get; }
  4.     public IDragDropPayload TargetPayload { get; }
  5. }
  6. public sealed class DragDropMessage<TSource, TTarget> : IDragDropMessage
  7.     where TSource : View
  8.     where TTarget : View
  9. {
  10.     public required DragDropPayload<TSource> SourcePayload { get; init; }
  11.     public required DragDropPayload<TTarget> TargetPayload { get; init; }
  12.     IDragDropPayload IDragDropMessage.SourcePayload => SourcePayload;
  13.     IDragDropPayload IDragDropMessage.TargetPayload => TargetPayload;
  14. }
复制代码
关键点

  • 类型安全:通过 TSource 和 TTarget 约束拖放的源/目标类型(如 Label→Border)。
  • 数据透传:通过 DataPackagePropertySet 传递扩展属性,避免消息类字段膨胀。
  • 解耦业务:消息仅负责数据传递,具体逻辑由订阅者(如 MainPage)处理。
3. AsDraggable 扩展方法

通过扩展方法为任意控件注入拖放能力,屏蔽手势识别细节。
  1.     public static void AsDraggable<TSource>(this TSource source, object? sourceAffix = null, Action? sourceCallback = null)
  2.         where TSource : View
  3.     {
  4.         // 创建并存储 payload
  5.         var payload = new DragDropPayload<TSource>
  6.         {
  7.             View = source,
  8.             Affix = sourceAffix,
  9.             Callback = sourceCallback
  10.         };
  11.         // 覆盖现有 payload(如果存在)
  12.         dragPayloads.AddOrUpdate(source, payload);
  13.         // 查找或创建 DragGestureRecognizer
  14.         var dragGesture = source.GestureRecognizers.OfType<DragGestureRecognizer>().FirstOrDefault();
  15.         if (dragGesture == null)
  16.         {
  17.             dragGesture = new DragGestureRecognizer { CanDrag = true };
  18.             source.GestureRecognizers.Add(dragGesture);
  19.             // 只在首次添加手势时注册事件
  20.             dragGesture.DragStarting += (sender, args) =>
  21.             {
  22.                 // 通过 dragPayloads 提取最新的 payload
  23.                 if (dragPayloads.TryGetValue(source, out var dragPayload) && dragPayload is DragDropPayload<TSource> payload)
  24.                 {
  25.                     args.Data.Properties.Add("SourcePayload", payload);
  26.                     source.Opacity = 0.5;
  27.                 }
  28.             };
  29.         }
  30.     }
复制代码
4. AsDroppable 扩展方法
  1. public static void AsDroppable<TTarget>(this TTarget target, object? targetAffix = null, Action? targetCallback = null)
  2.     where TTarget : View
  3. {
  4.     AsDroppable<View, TTarget>(target, targetAffix, targetCallback);
  5. }
  6. public static void AsDroppable<TSource, TTarget>(this TTarget target, object? targetAffix = null, Action? targetCallback = null)
  7.     where TSource : View
  8.     where TTarget : View
  9. {
  10.     var dropGesture = target.GestureRecognizers.OfType<DropGestureRecognizer>().FirstOrDefault();
  11.     if (dropGesture is null)
  12.     {
  13.         dropGesture = new DropGestureRecognizer() { AllowDrop = true };
  14.         target.GestureRecognizers.Add(dropGesture);
  15.         DragDropPayload<TTarget> defaultPayload = new()
  16.         {
  17.             View = target,
  18.             Affix = null,
  19.             Callback = null
  20.         };
  21.         _ = dropPayloads
  22.             .GetOrCreateValue(dropGesture)
  23.             .GetOrAdd(typeof(View).Name, _ => defaultPayload);
  24.         dropGesture.DragOver += (sender, args) =>
  25.         {
  26.             bool isSupported = args.Data.Properties.TryGetValue("SourcePayload", out _);
  27.             target.BackgroundColor = isSupported ? Colors.LightGreen : Colors.Transparent;
  28.         };
  29.         dropGesture.DragLeave += (sender, args) =>
  30.         {
  31.             target.BackgroundColor = Colors.Transparent;
  32.         };
  33.         dropGesture.Drop += (s, e) => OnDroppablesMessage<TTarget>(target, dropGesture, e);
  34.     }
  35.     DragDropPayload<TTarget> sourceSpecificDropPayload = new()
  36.     {
  37.         View = target,
  38.         Affix = targetAffix,
  39.         Callback = targetCallback
  40.     };
  41.     var payloadDict = dropPayloads.GetOrCreateValue(dropGesture);
  42.     _ = payloadDict.AddOrUpdate(typeof(TSource).Name, (s) => sourceSpecificDropPayload, (s, old) => sourceSpecificDropPayload);
  43. }
复制代码
核心机制

  • 手势识别器:使用 DragGestureRecognizer 和 DropGestureRecognizer 捕获拖放事件。  保持实例唯一。
  • 类型映射表:静态存储器 dragPayloads / dropPayloads 存储可支持的拖、放对象及其附加的数据,保持最新。
  • 消息注册:为每种类型组合注册唯一的消息处理函数,确保消息精准投递。
  • 方法重载:AsDroppable ,无特殊数据和动作附加的,可简化处理,毋须逐一注册类型配对。
二、关键实现细节

1.  ConditionalWeakTable

在 DragDropExtensions 中,我们使用两个 ConditionalWeakTable 实现状态管理,保证拖放事件发生时传递最新约定的数据。
ConditionalWeakTable 最大的好处是避免内存泄漏。用 View 或 GestureRecognizer 实例作为键,当该实例不再被别处引用时,内存回收机制会自动清除对应的键值对,无需用户专门释放内存。
  1. private static readonly ConditionalWeakTable<View, IDragDropPayload> dragPayloads = [];
  2. private static readonly ConditionalWeakTable<GestureRecognizer, ConcurrentDictionary<string, IDragDropPayload>> dropPayloads = [];
复制代码
2. dropPayloads

为每个 DropGestureRecognizer 关联源类型映射和对应该源类型所预先配置目标类型 TargetPayload。
  1. DragDropPayload<TTarget> sourceSpecificDropPayload = new()
  2. {
  3.     View = target,
  4.     Affix = targetAffix,
  5.     Callback = targetCallback
  6. };
  7. var payloadDict = dropPayloads.GetOrCreateValue(dropGesture);
  8. _ = payloadDict.AddOrUpdate(typeof(TSource).Name, (s) => sourceSpecificDropPayload, (s, old) => sourceSpecificDropPayload);
复制代码
还贴心地预备好默认配置:
  1. DragDropPayload<TTarget> defaultPayload = new()
  2. {
  3.     View = target,
  4.     Affix = null,
  5.     Callback = null
  6. };
  7. _ = dropPayloads
  8.     .GetOrCreateValue(dropGesture)
  9.     .GetOrAdd(typeof(View).Name, _ => defaultPayload);
复制代码
3 . dragPayloads

源类型 SourcePayload 配置表,在 DragGestureRecognizer 首次配置时注册,重复 AsDraggable 方法时更新。
  1. // 创建并存储 payload
  2. var payload = new DragDropPayload<TSource>
  3. {
  4.     View = source,
  5.     Affix = sourceAffix,
  6.     Callback = sourceCallback
  7. };
  8. // 覆盖现有 payload(如果存在)
  9. dragPayloads.AddOrUpdate(source, payload);
复制代码
4 .  IDragDropMessage / WeakReferenceMessenger

反射获取分类拖放消息,但需要统一发送:
  1. // 构建泛型类型
  2. Type genericMessageType = typeof(DragDropMessage<,>);
  3. Type constructedMessageType = genericMessageType.MakeGenericType(sourceType, typeof(TTarget));
  4. // 创建实例
  5. object? message = Activator.CreateInstance(constructedMessageType);
  6. if (message is null)
  7. {
  8.     return;
  9. }
  10. // 设置属性
  11. PropertyInfo sourceProp = constructedMessageType.GetProperty("SourcePayload")!;
  12. PropertyInfo targetProp = constructedMessageType.GetProperty("TargetPayload")!;
  13. sourceProp.SetValue(message, sourcePayload);
  14. targetProp.SetValue(message, targetPayload);
  15. // 核心动作
  16. _ = WeakReferenceMessenger.Default.Send<IDragDropMessage>((IDragDropMessage)message);
复制代码
三、 反射的优化

尝试了很多办法,还是采用反射技术,最为直接。
我并不喜欢使用反射。消耗大不说,现在 Microsoft 大力推进 Native AOT( Ahead Of Time)编译,将.NET 代码提前编译为本机代码,对反射的使用有约束,如果代码中反射模式导致 AOT 编译器无法静态分析,就会产生裁剪警告,甚至可能导致编译失败或运行时异常。
因此,在 .NET MAUI 的 AOT 编译环境下,对反射泛型类型的创建需要特殊处理。这里通过 预编译委托缓存 + 静态类型注册 的组合方案,实现了AOT 的泛型消息工厂。高效是肯定的,目前看来,是兼容的。
使用 ConcurrentDictionary 存储注册的源类型和目标类型,通过 "Source" 和 "Target" 两个键区分不同角色的类型集合, HashSet 确保类型唯一性,避免重复注册。
  1. private static readonly ConcurrentDictionary<string, HashSet<Type>> registeredTypes = new();
复制代码
自动配对机制:当新类型注册时,自动与已注册的对立类型(源→目标,目标→源)创建所有可能的配对组合(静态),确保 AOT 环境下反射可用。
  1. private static void RegisterType(string role, Type type)
  2. {
  3.   // 获取或创建对应角色的类型集合
  4.   var types = registeredTypes.GetOrAdd(role, _ => []);
  5.   // 添加类型并判断是否为新增(返回true表示新增)
  6.   if (types.Add(type))
  7.   {
  8.       // 新注册的类型,补全所有可能的配对组合
  9.       if (role == "Source")
  10.       {
  11.           // 源类型:与所有已注册的目标类型配对
  12.           if (registeredTypes.TryGetValue("Target", out var targetTypes))
  13.           {
  14.               foreach (var targetType in targetTypes)
  15.               {
  16.                   RegisterMessageFactory(type, targetType);
  17.               }
  18.           }
  19.       }
  20.       else if (role == "Target")
  21.       {
  22.           // 目标类型:与所有已注册的源类型配对
  23.           if (registeredTypes.TryGetValue("Source", out var sourceTypes))
  24.           {
  25.               foreach (var sourceType in sourceTypes)
  26.               {
  27.                   RegisterMessageFactory(sourceType, type);
  28.               }
  29.           }
  30.       }
  31.   }
  32. }
复制代码
反射泛型工厂:每个类型组合仅反射一次,生成的委托被缓存
  1. private static readonly ConcurrentDictionary<(Type source, Type target), Func<IDragDropPayload, IDragDropPayload, IDragDropMessage>> messageFactories = new();
  2. private static void RegisterMessageFactory(Type sourceType, Type targetType)
  3. {
  4.     var key = (sourceType, targetType);
  5.     messageFactories.GetOrAdd(key, _ => {
  6.         // 仅首次执行反射
  7.         var messageType = typeof(DragDropMessage<,>).MakeGenericType(sourceType, targetType);
  8.         return (sourcePayload, targetPayload) => {
  9.             var message = Activator.CreateInstance(messageType)!;
  10.             // 设置属性...
  11.             return (IDragDropMessage)message;
  12.         };
  13.     });
  14. }
复制代码
反射优化策略:后续调用直接执行委托,避免重复反射
  1. // 通过预注册的工厂创建消息实例
  2. var key = (sourceType, typeof(TTarget));
  3. if (messageFactories.TryGetValue(key, out var factory))
  4. {
  5.     var message = factory(sourcePayload, targetPayload);
  6.     // 核心动作
  7.     _ = WeakReferenceMessenger.Default.Send<IDragDropMessage>(message);
  8. }
复制代码
AOT 兼容性保障
预编译委托缓存方案,支持任意类型组合,仅首次注册时有反射开销,平衡灵活性和性能,但需要在编译前静态注册所有可能的类型组合,避免运行时动态生成未知类型组合。
必要的话,可使用 [assembly: Preserve] 属性保留泛型类型及其成员。暂时没采用这种方法,寄希望于 Microsoft 自行保证兼容性。
四、使用示例

MainPage.xaml
  1. <?xml version="1.0" encoding="utf-8" ?>
  2. <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
  3.              xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  4.              x:
  5.              Title="拖放示例">
  6.     <StackLayout Spacing="20" Padding="30">
  7.         <Label Text="高级拖放示例"
  8.                FontSize="22"
  9.                FontAttributes="Bold"
  10.                HorizontalOptions="Center" />
  11.         <HorizontalStackLayout
  12.                 HorizontalOptions="Center">
  13.             <Label x:Name="DragLabel"
  14.                 Text="拖放示例文本"
  15.                 BackgroundColor="LightBlue"
  16.                 Padding="12"
  17.                 HorizontalOptions="Center"
  18.                 FontSize="16" />
  19.             <BoxView x:Name="DragBoxView"
  20.                 HeightRequest="60"
  21.                 WidthRequest="120"
  22.                 BackgroundColor="LightPink"
  23.                 HorizontalOptions="Center" />
  24.             <ContentView x:Name="DragContentView"
  25.                 HeightRequest="60"
  26.                 WidthRequest="120"
  27.                 BackgroundColor="LightCyan"
  28.                 HorizontalOptions="Center" />
  29.         </HorizontalStackLayout>
  30.         <Border x:Name="DropBorder"
  31.                BackgroundColor="LightGreen"
  32.                Padding="20"
  33.                Margin="10"
  34.                HorizontalOptions="Center"
  35.                WidthRequest="200"
  36.                HeightRequest="100">
  37.             <Label Text="放置目标区域" HorizontalOptions="Center" />
  38.         </Border>
  39.         <Label x:Name="ResultLabel"
  40.                Text="等待拖放操作..."
  41.                HorizontalOptions="Center"
  42.                FontAttributes="Italic"
  43.                TextColor="Gray" />
  44.     </StackLayout>
  45. </ContentPage>   
复制代码
MainPage.xaml.cs
  1. using CommunityToolkit.Mvvm.Messaging;
  2. using System.Diagnostics;
  3. using Zhally.DragDrop.Controls;
  4. namespace Zhally.DragDrop;
  5. public partial class MainPage : ContentPage
  6. {
  7.     public MainPage()
  8.     {
  9.         InitializeComponent();
  10.         SetupDragDrop();
  11.     }
  12.     private void SetupDragDrop()
  13.     {
  14.         // 设置可拖动元素(携带 Payload 数据)
  15.         DragLabel.AsDraggable<Label>(
  16.             sourceAffix: new { Type = "文本数据", Value = "Hello World" },
  17.             sourceCallback: () => Debug.WriteLine("拖动源回调")
  18.         );
  19.         DragLabel.AsDraggable<Label>(
  20.             sourceAffix: new { Type = "文本数据", Value = "Hello World agian" },
  21.             sourceCallback: () => Debug.WriteLine("拖动源回调 again")
  22.         );
  23.         DragBoxView.AsDraggable<BoxView>(
  24.             sourceAffix: new { Type = "BoxView数据", Value = "BoxView" },
  25.             sourceCallback: () => Debug.WriteLine("按钮拖动回调")
  26.         );
  27.         DragContentView.AsDraggable<ContentView>(
  28.             sourceAffix: new { Type = "ContentView数据", Value = "ContentView" },
  29.             sourceCallback: () => Debug.WriteLine("按钮拖动回调")
  30.         );
  31.         // 设置可放置元素(携带目标数据)
  32.         DropBorder.AsDroppable<Label, Border>(
  33.           targetAffix: new { Type = "目标数据", Value = "Label Drop Zone" },
  34.           targetCallback: () => Debug.WriteLine("放置目标回调")
  35.         );
  36.         DropBorder.AsDroppable<BoxView, Border>(
  37.           targetAffix: new { Type = "目标数据", Value = "BoxView Drop Zone" },
  38.           targetCallback: () => Debug.WriteLine("放置目标回调")
  39.         );
  40.         // 设置可放置元素(通用,非必须,在携带目标数据时有用)
  41.         DropBorder.AsDroppable<Border>(
  42.           targetAffix: new { Type = "目标数据", Value = "Generic Drop Zone" },
  43.           targetCallback: () => Debug.WriteLine("放置目标回调")
  44.         );
  45.     }
  46.     protected override void OnAppearing()
  47.     {
  48.         base.OnAppearing();
  49.         WeakReferenceMessenger.Default.Register<IDragDropMessage>(this, HandleBorderDragDropMessage);
  50.     }
  51.     protected override void OnDisappearing()
  52.     {
  53.         base.OnDisappearing();
  54.         WeakReferenceMessenger.Default.UnregisterAll(this);
  55.     }
  56.     private void HandleBorderDragDropMessage(object recipient, IDragDropMessage message)
  57.     {
  58.         if (message.SourcePayload.View == null || message.TargetPayload.View == null)
  59.         {
  60.             return;
  61.         }
  62.         switch (message.SourcePayload.View)
  63.         {
  64.             case Label label:
  65.                 HandleLabelDrop(label, message);
  66.                 break;
  67.             case BoxView boxView:
  68.                 HandleBoxViewDrop(boxView, message);
  69.                 break;
  70.             case ContentView contentView:
  71.                 HandleContentViewDrop(contentView, message);
  72.                 break;
  73.             default:
  74.                 HandleDefaultDrop(message);
  75.                 break;
  76.         }
  77.     }
  78.     private void HandleDefaultDrop(IDragDropMessage message) => HandleBorderMessage(message);
  79.     private void HandleLabelDrop(Label label, IDragDropMessage message) => HandleBorderMessage(message);
  80.     private void HandleBoxViewDrop(BoxView boxView, IDragDropMessage message) => HandleBorderMessage(message);
  81.     private void HandleContentViewDrop(ContentView contentView, IDragDropMessage message) => HandleBorderMessage(message);
  82.     private void HandleBorderMessage(IDragDropMessage message)
  83.     {
  84.         MainThread.BeginInvokeOnMainThread(() =>
  85.         {
  86.             ResultLabel.Text = $"拖放成功!\n" +
  87.                               $"源类型: {message.SourcePayload.View.GetType()}\n" +
  88.                               $"源数据: {message.SourcePayload.Affix}\n" +
  89.                               $"目标数据: {message.TargetPayload.Affix}";
  90.         });
  91.         // 执行回调
  92.         MainThread.BeginInvokeOnMainThread(() =>
  93.         {
  94.             message.SourcePayload.Callback?.Invoke();  // 执行源回调
  95.         });
  96.         // 执行回调
  97.         MainThread.BeginInvokeOnMainThread(() =>
  98.         {
  99.             message.TargetPayload.Callback?.Invoke();   // 执行目标回调
  100.         });
  101.     }
  102. }
复制代码
五、总结

本方案实现了 MAUI 控件拖放能力的动态扩展。核心设计遵循以下原则:

  • 解耦:拖放逻辑与控件分离,通过消息系统连接业务层。
  • 类型安全:泛型约束确保拖放类型匹配,编译期暴露潜在问题。
  • 可扩展:通过字典映射和消息订阅,轻松支持新的拖放类型组合。
此方案已在实际项目中验证,适用于文件管理、列表排序、数据可视化等场景,为 MAUI 应用提供了灵活高效的拖放解决方案。
本方案源代码开源,按照 MIT 协议许可。地址:xiaql/Zhally.Toolkit: Dynamically attach draggable and droppable capability to controls of View in MAUI

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册