Maui 實踐:為控件動態擴展 DragDrop 能力
作者:夏群林 原創 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;
}
關鍵點:
- 類型安全:通過
TSource和TTarget約束拖放的源/目標類型(如Label→Border)。 - 數據透傳:通過
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);
}
核心機制:
- 手勢識別器:使用
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<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 控件拖放能力的動態擴展。核心設計遵循以下原則:
- 解耦:拖放邏輯與控件分離,通過消息系統連接業務層。
- 類型安全:泛型約束確保拖放類型匹配,編譯期暴露潛在問題。
- 可擴展:通過字典映射和消息訂閱,輕松支持新的拖放類型組合。
此方案已在實際項目中驗證,適用于文件管理、列表排序、數據可視化等場景,為 MAUI 應用提供了靈活高效的拖放解決方案。
本方案源代碼開源,按照 MIT 協議許可。地址:xiaql/Zhally.Toolkit: Practices on specific tool kit in MAUI

浙公網安備 33010602011771號