作者:夏群林 原创 2025.6.9

拖放的实现,和其他的 GestureRecognizer 不同,需要 DragGestureRecognizer 与 DropGestureRecognizer 相互配合,Drag / Drop 又是在不同的控件上附加的,数据传输和配置相对复杂,不太好理解。需要彻底阅读源代码,才能真的把握。我做了一个扩展方法,把复杂的配置包裹起来,在代码层面与要附加拖放功能的控件分离,用户只需关注拖放动作所支持的业务功能即可。

直接上代码。

一、核心架构与关键组件

1. 数据载体:DragDropPayload<TView>

解耦控件与业务逻辑,封装拖放所需的视图引用、附加数据和回调逻辑。

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<TSource, TTarget>

通过泛型消息明确拖放类型,实现跨层业务逻辑解耦。 这里也配置了协变,便于 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;
}

关键点

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

3. AsDraggable<TSource> 扩展方法

通过扩展方法为任意控件注入拖放能力,屏蔽手势识别细节。

    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<TSource, TTarget> 扩展方法

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);
}

核心机制

  • 手势识别器:使用 DragGestureRecognizerDropGestureRecognizer 捕获拖放事件。 保持实例唯一。
  • 类型映射表:静态存储器 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<string, HashSet<Type>> 存储注册的源类型和目标类型,通过 "Source""Target" 两个键区分不同角色的类型集合, HashSet<Type> 确保类型唯一性,避免重复注册。

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:Class="Zhally.DragDrop.MainPage"
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 控件拖放能力的动态扩展。核心设计遵循以下原则:

  1. 解耦:拖放逻辑与控件分离,通过消息系统连接业务层。
  2. 类型安全:泛型约束确保拖放类型匹配,编译期暴露潜在问题。
  3. 可扩展:通过字典映射和消息订阅,轻松支持新的拖放类型组合。

此方案已在实际项目中验证,适用于文件管理、列表排序、数据可视化等场景,为 MAUI 应用提供了灵活高效的拖放解决方案。

本方案源代码开源,按照 MIT 协议许可。地址:xiaql/Zhally.Toolkit: Dynamically attach draggable and droppable capability to controls of View in MAUI

Maui 实践:为控件动态扩展 DragDrop 能力的更多相关文章

  1. TextBoxFor控件的扩展---Bootstrap在mvc上的应用

    TextBoxFor控件的问题: 1:自带了样式,再用bootstrap样式会有冲突. 2:要加水印,js事件,限制输入长度比较麻烦. 因此需要对textboxfor控件进行扩展. 目标: 1:能使用 ...

  2. 验证控件插图扩展控件ValidatorCalloutExtender(用于扩展验证控件)和TextBoxWatermarkExtender

    <asp:ScriptManager ID="ScriptManager1" runat="server">  </asp:ScriptMan ...

  3. C# DataGridView控件 动态添加新行

    DataGridView控件在实际应用中非常实用,特别需要表格显示数据时.可以静态绑定数据源,这样就自动为DataGridView控件添加相应的行.假如需要动态为DataGridView控件添加新行, ...

  4. Delphi7 第三方控件1stClass4000的TfcImageBtn按钮控件动态加载jpg图片例子

    Delphi7 第三方控件1stClass4000的TfcImageBtn按钮控件动态加载jpg图片例子 procedure TForm1.Button1Click(Sender: TObject); ...

  5. 不在界面上用控件 动态创建idhttp,IdAntiFreeze来用

    不在界面上用控件 动态创建idhttp,IdAntiFreeze来用 var IdHTTP: Tidhttp; IdAntiFreeze: TidAntiFreeze; begin IdAntiFre ...

  6. C# DataGridView控件动态添加新行

    C# DataGridView控件动态添加新行 DataGridView控件在实际应用中非常实用,特别需要表格显示数据时.可以静态绑定数据源,这样就自动为DataGridView控件添加相应的行.假如 ...

  7. 【C#】使用IExtenderProvider为控件添加扩展属性,像ToolTip那样

    申明: - 本文适用于WinForm开发 - 文中的“控件”一词是广义上的说法,泛指包括ToolStripItem.MenuItem在内单个界面元素,并不特指继承自Control类的狭义控件 用过To ...

  8. DotNetBar RibbonControl 控件动态添加项

    想做个插件式开发,界面用Dotnetbar的RibbonControl,需要通过代码动态的向RibbonControl控件添加项 示例代码如下: RibbonTabItem rti = new Rib ...

  9. MFC中List控件动态填充数据(LVN_GETDISPINFO)

    在使用List控件的过程中,有时候List控件中需要添加大量的数据,如果使用InsertItem填充,会一次性将数据全部添加进List控件中,比较耗时.这里记录下如何动态添加List控件数据. 步骤 ...

  10. UniGUI的 TUniPageControl控件动态拖动tabsheet的实现方法

    https://blog.csdn.net/shuiying/article/details/54932518 实现可以用鼠标动态拖动tabsheet,共三个步骤: 1.在ServerModule中, ...

随机推荐

  1. php-fpm 启动后没有监听端口9000

    netstat -tpln未发现监听9000端口.查看/var/log/php7-fpm.log一切正常. 随后查看PHP配置文件:/usr/local/php/etc/php-fpm.conf (源 ...

  2. docker 中几个节点意外宕机 pxc 无法启动

    docker 意外宕机,PXC启动不了解决方法 由于 意外宕机 docker start pxc 节点后闪退,解决方法如下 从节点中找任意一个数据卷映射目录,修改参数 [root@izuf64gdeg ...

  3. EBUSY: resource busy or locked, rmdir

    方案一: 方案二: !!! 出现问题后,千万不要忽略npm提示你的警告... 如果以上两种方案还未解决,那么大概率是因为你的npm版本较低导致的,升级你的npm. cnpm install -g np ...

  4. BUUCTF---异性相吸(欠编码)

    1.题目 ܟࠄቕ̐员䭜塊噓䑒̈́ɘ䘆呇Ֆ䝗䐒嵊ᐛ asadsasdasdasdasdasdasdasdasdasdqwesqf 2.知识 3.解题 很奇怪,不知道什么加密,借鉴网上参考,得知需将其转化为 ...

  5. 区块链特辑——solidity语言基础(四)

    Solidity语法基础学习 七.事件: 事件 Event ·日志(log),是用来快速索引并查询过往资料的手段. ·而solidity是透过"事件"在区块链上写下日志,使用者或由 ...

  6. 【电脑】VirtualBox 安装 Win98 写网页

    VirtualBox 安装 Win98 写网页 记录一下,我成功在VirtualBox中安装了Win98系统,并且安装了Dreamweaver 3.0写网页. 零.起因 学校机房的极域电子教室软件不太 ...

  7. Microsoft.NETCore.App 版本不一致导致的运行失败

    场景重现 今天新建了一个 ASP.NET Core 的项目, 通过 Web Deploy 顺利发布到IIS上后, 但访问时出现如下异常: 异常原因 通过手动执行dotnet命令发现运行框架版本不一致? ...

  8. MySQL 的默认字符集为什么是 latin1?

    是处于历史原因还是其他? 为什么至今不选择utf-8? West European Character Sets

  9. 工具推荐-根据IP地址精确定位经纬度(永久免费)

    今天小张由于业务需求,需要根据用户的访问ip精确定位用户的国家.城市.及经纬度等信息,从网上进行搜索,发现不少的网站,但几乎没有完全符合的,有个别符合的还需要花钱,大家也知道,现在是信息共享的时代,难 ...

  10. 多线程,Join()

    一.定义:就是该线程是指的主线程等待子线程的终止.也就是在子线程调用了join()方法,后面的代码,只有等到子线程结束了才能执行 二.不加join: class Thread1 extends Thr ...