@
上一章我們使用拖放(drag-drop)手勢識別實現了可拖拽排序列表,對於列表中的條目,完整的拖拽排序過程是:
手指觸碰條目 -> 拖拽條目 -> 拖拽懸停在另一個條目上方 -> 鬆開手指 -> 移動條目至此處。
其是在鬆開手指之後才向列表提交條目位置變更的命令。今天我們換一個寫法,將拖拽條目放置在另一個條目上方時,即可將條目位置變更。即實時拖拽排序。
使用.NET MAU實現跨平臺支援,本專案可執行於Android、iOS平臺。
建立頁面元素
新建.NET MAUI專案,命名Tile
本章的例項中使用網格佈局的CollectionView控制元件作為Tile的容器。
CollectionView 的其他佈局方式請參考官方文件 指定 CollectionView 佈局
建立GridTilesPage.xaml
在頁面中建立CollectionView,
<CollectionView Grid.Row="1"
x:Name="MainCollectionView"
ItemsSource="{Binding TileSegments}">
<CollectionView.ItemTemplate>
<DataTemplate>
<ContentView HeightRequest="110" WidthRequest="110" HorizontalOptions="Center" VerticalOptions="Center">
<StackLayout>
<StackLayout.GestureRecognizers>
<DropGestureRecognizer AllowDrop="True"
DragLeaveCommand="{Binding DragLeave}"
DragLeaveCommandParameter="{Binding}"
DragOverCommand="{Binding DraggedOver}"
DragOverCommandParameter="{Binding}"
DropCommand="{Binding Dropped}"
DropCommandParameter="{Binding}" />
</StackLayout.GestureRecognizers>
<Border x:Name="ContentLayout"
StrokeThickness="0"
Margin="0">
<Grid>
<Grid.GestureRecognizers>
<DragGestureRecognizer CanDrag="True"
DragStartingCommand="{Binding Dragged}"
DragStartingCommandParameter="{Binding}" />
</Grid.GestureRecognizers>
<controls1:TileSegmentView HeightRequest="100"
WidthRequest="100"
Margin="5,5">
</controls1:TileSegmentView>
<Button CornerRadius="100"
HeightRequest="20"
WidthRequest="20"
Padding="0"
Margin="2,2"
BackgroundColor="Red"
TextColor="White"
Command="{Binding Remove}"
Text="×"
HorizontalOptions="End"
VerticalOptions="Start"></Button>
</Grid>
</Border>
</StackLayout>
</ContentView>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical"
Span="3" />
</CollectionView.ItemsLayout>
</CollectionView>
呈現效果如下:
DropGestureRecognizer中設定了拖拽懸停、離開、放置時的命令,
建立IDraggableItem介面, 此處定義拖動相關的屬性和命令。
public interface IDraggableItem
{
bool IsBeingDraggedOver { get; set; }
bool IsBeingDragged { get; set; }
Command Dragged { get; set; }
Command DraggedOver { get; set; }
Command DragLeave { get; set; }
Command Dropped { get; set; }
object DraggedItem { get; set; }
object DropPlaceHolderItem { get; set; }
}
Dragged: 拖拽開始時觸發的命令。
DraggedOver: 拖拽控制元件懸停在當前控制元件上方時觸發的命令。
DragLeave: 拖拽控制元件離開當前控制元件時觸發的命令。
Dropped: 拖拽控制元件放置在當前控制元件上方時觸發的命令。
IsBeingDragged 為true時,通知當前控制元件正在被拖拽。
IsBeingDraggedOver 為true時,通知當前控制元件正在有拖拽控制元件懸停在其上方。
DraggedItem: 正在拖拽的控制元件。
DropPlaceHolderItem: 懸停在其上方時的控制元件,即當前控制元件的佔位控制元件。
建立一個TileSegement類,用於描述磁貼可顯示的屬性,如標題、描述、圖示、顏色等。
public class TileSegment
{
public string Title { get; set; }
public string Type { get; set; }
public string Desc { get; set; }
public string Icon { get; set; }
public Color Color { get; set; }
}
建立可繫結物件
建立GridTilesPageViewModel,建立繫結服務類集合TileSegments。
private ObservableCollection<ITileSegmentService> _tileSegments;
public ObservableCollection<ITileSegmentService> TileSegments
{
get { return _tileSegments; }
set
{
_tileSegments = value;
OnPropertyChanged();
}
}
建構函式中初始化一些不同顏色的磁貼,並將TileSegementService.Container設定為自己(this)。
public GridTilesPageViewModel()
{
TileSegments = new ObservableCollection<ITileSegmentService>();
CreateSegmentAction("TileSegment", "App1", "Some description here", Colors.LightPink);
CreateSegmentAction("TileSegment", "App2", "Some description here", Colors.LightGreen);
...
}
private ITileSegmentService CreateTileSegmentService(object obj, string title, string desc, Color color)
{
var type = obj as string;
var tileSegment = new TileSegment()
{
Title = title,
Type = type,
Desc = desc,
Icon = "dotnet_bot.svg",
Color = color,
};
var newModel = new GridTileSegmentService(tileSegment);
if (newModel != null)
{
newModel.Container = this;
}
return newModel;
}
建立繫結服務類
建立可拖拽控制元件的繫結服務類GridTileSegmentService,繼承ObservableObject,並實現IDraggableItem介面。
建立ICommand屬性:Dragged, DraggedOver, DragLeave, Dropped。
訂閱PropertyChanged事件以便在屬性更改時觸發相關操作
public class GridTileSegmentService : ObservableObject, ITileSegmentService
{
public GridTileSegmentService(TileSegment tileSegment)
{
TileSegment = tileSegment;
Dragged = new Command(OnDragged);
DraggedOver = new Command(OnDraggedOver);
DragLeave = new Command(OnDragLeave);
Dropped = new Command(i => OnDropped(i));
this.PropertyChanged+=GridTileSegmentService_PropertyChanged;
}
...
}
拖拽(Drag)
拖拽開始時,將IsBeingDragged設定為true,通知當前控制元件正在被拖拽,同時將DraggedItem設定為當前控制元件。
private void OnDragged(object item)
{
IsBeingDragged=true;
DraggedItem=item;
}
拖拽懸停,經過(DragOver)
拖拽控制元件懸停在當前控制元件上方時,將IsBeingDraggedOver設定為true,通知當前控制元件正在有拖拽控制元件懸停在其上方,同時在服務列表中尋找當前正在被拖拽的服務,將DropPlaceHolderItem設定為當前控制元件。
private void OnDraggedOver(object item)
{
if (!IsBeingDragged && item!=null)
{
var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);
if (itemToMove.DraggedItem!=null)
{
DropPlaceHolderItem=itemToMove.DraggedItem;
}
IsBeingDraggedOver=true;
}
}
離開控制元件上方時,IsBeingDraggedOver設定為false
private void OnDragLeave(object item)
{
IsBeingDraggedOver = false;
DropPlaceHolderItem = null;
}
透過訂閱PropertyChanged, 在GridTileSegmentService_PropertyChanged方法中響應IsBeingDraggedOver屬性的值變更。
當IsBeingDraggedOver為True時代表有拖拽中控制元件懸停在其上方,DropPlaceHolderItem即為懸停在其上方的控制元件物件。
此時我們應該將懸停在其上方的控制元件物件插入到自身的前方,透過獲取兩者在集合的角標並呼叫Move()
方法。
private void GridTileSegmentService_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName==nameof(this.IsBeingDraggedOver))
{
if (this.IsBeingDraggedOver && DropPlaceHolderItem!=null)
{
var newIndex = Container.TileSegments.IndexOf(this);
var oldIndex = Container.TileSegments.IndexOf(DropPlaceHolderItem as ITileSegmentService);
Container.TileSegments.Move(oldIndex, newIndex);
}
}
}
效果如下:
釋放(Drop)
拖拽完成時,獲取當前正在被拖拽的控制元件,將其從服務列表中移除,然後將其插入到當前控制元件的位置,通知當前控制元件拖拽完成。
private void OnDropped(object item)
{
var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);
if (itemToMove == null)
return;
itemToMove.IsBeingDragged = false;
IsBeingDraggedOver = false;
DraggedItem=null;
DropPlaceHolderItem = null;
}
完整的TileSegmentService程式碼如下:
public class GridTileSegmentService : ObservableObject, ITileSegmentService
{
public GridTileSegmentService(
TileSegment tileSegment)
{
Remove = new Command(RemoveAction);
TileSegment = tileSegment;
Dragged = new Command(OnDragged);
DraggedOver = new Command(OnDraggedOver);
DragLeave = new Command(OnDragLeave);
Dropped = new Command(i => OnDropped(i));
this.PropertyChanged+=GridTileSegmentService_PropertyChanged;
}
private void GridTileSegmentService_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName==nameof(this.IsBeingDraggedOver))
{
if (this.IsBeingDraggedOver && DropPlaceHolderItem!=null)
{
var newIndex = Container.TileSegments.IndexOf(this);
var oldIndex = Container.TileSegments.IndexOf(DropPlaceHolderItem as ITileSegmentService);
Container.TileSegments.Move(oldIndex, newIndex);
}
}
}
private void OnDragged(object item)
{
IsBeingDragged=true;
DraggedItem=item;
}
private void OnDraggedOver(object item)
{
if (!IsBeingDragged && item!=null)
{
var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);
if (itemToMove.DraggedItem!=null)
{
DropPlaceHolderItem=itemToMove.DraggedItem;
}
IsBeingDraggedOver=true;
}
}
private object _draggedItem;
public object DraggedItem
{
get { return _draggedItem; }
set
{
_draggedItem = value;
OnPropertyChanged();
}
}
private object _dropPlaceHolderItem;
public object DropPlaceHolderItem
{
get { return _dropPlaceHolderItem; }
set
{
_dropPlaceHolderItem = value;
OnPropertyChanged();
}
}
private void OnDragLeave(object item)
{
IsBeingDraggedOver = false;
DropPlaceHolderItem = null;
}
private void OnDropped(object item)
{
var itemToMove = Container.TileSegments.First(i => i.IsBeingDragged);
if (itemToMove == null)
return;
itemToMove.IsBeingDragged = false;
IsBeingDraggedOver = false;
DraggedItem=null;
DropPlaceHolderItem = null;
}
private async void RemoveAction(object obj)
{
if (Container is ITileSegmentServiceContainer)
{
(Container as ITileSegmentServiceContainer).RemoveSegment.Execute(this);
}
}
public IReadOnlyTileSegmentServiceContainer Container { get; set; }
private TileSegment tileSegment;
public TileSegment TileSegment
{
get { return tileSegment; }
set
{
tileSegment = value;
OnPropertyChanged();
}
}
private bool _isBeingDragged;
public bool IsBeingDragged
{
get { return _isBeingDragged; }
set
{
_isBeingDragged = value;
OnPropertyChanged();
}
}
private bool _isBeingDraggedOver;
public bool IsBeingDraggedOver
{
get { return _isBeingDraggedOver; }
set
{
if (value!=_isBeingDraggedOver)
{
_isBeingDraggedOver = value;
OnPropertyChanged();
}
}
}
public Command Remove { get; set; }
public Command Dragged { get; set; }
public Command DraggedOver { get; set; }
public Command DragLeave { get; set; }
public Command Dropped { get; set; }
}
執行程式,此時我們可以看到拖拽控制元件懸停在其它控制元件上方時,其它控制元件會自動調整位置。
限流(Throttle)和防抖(Debounce)
在特定平臺的列表控制元件中更新專案集合時,引發的動畫效果會導致列表中的控制元件位置錯亂。
當以比較快的速度,拖拽Tile經過較多的位置時,後面的Tile會短暫地替代原先的位置,導致拖拽中的Tile不在期望的Tile上方,而拖拽中的Tile與錯誤的Tile產生了交疊從而觸發DraggedOver事件,導致錯亂。
在某些機型上甚至會引發錯亂的持續迴圈
一個辦法是禁用動畫,如在iOS中配置
listView.On<iOS>().SetRowAnimationsEnabled(false);
動效問題最終要解決。由於快速拖拽Tile經過較多的位置頻繁觸發Move操作,透過限制事件的觸發頻率,引入限流(Throttle)和防抖(Debounce)機制可以有效地解決這個問題。限流和防抖的作用如下圖:
程式碼引用自 ThrottleDebounce
在GridTileSegmentService中建立靜態限流器物件變數throttledAction。以及全域性鎖物件throttledLocker。
public static RateLimitedAction throttledAction = Debouncer.Debounce(null, TimeSpan.FromMilliseconds(500), leading: false, trailing: true);
public static object throttledLocker = new object();
改寫GridTileSegmentService_PropertyChanged如下:
private void GridTileSegmentService_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName==nameof(this.IsBeingDraggedOver))
{
if (this.IsBeingDraggedOver && DropPlaceHolderItem!=null)
{
lock (throttledLocker)
{
var newIndex = Container.TileSegments.IndexOf(this);
var oldIndex = Container.TileSegments.IndexOf(DropPlaceHolderItem as ITileSegmentService);
var originalAction = () =>
{
Container.TileSegments.Move(oldIndex, newIndex);
};
throttledAction.Update(originalAction);
throttledAction.Invoke();
}
}
}
}
此時,在500毫秒內,只會執行一次Move操作。問題解決!
因為有500毫秒的延遲,Tile響應上感覺沒有那麼“靈動”,這算是一種犧牲。在不同的平臺上可以調整這個時間以達到一種平衡,不知道螢幕前的你有沒有更好的方式解決呢?