From 38a0a9ed30ffef5a5ad83163a6b6fae8f2d4e1ad Mon Sep 17 00:00:00 2001 From: "Alexandre H.T.R. Bonfitto" Date: Thu, 8 Aug 2024 15:50:15 -0300 Subject: [PATCH] samples: add highlight effects to TreeView drag-and-drop --- .../Behaviors/BaseTreeViewDropHandler.cs | 135 ++++++++++++++++++ .../Behaviors/NodesTreeViewDropHandler.cs | 69 ++++----- .../ViewModels/MainWindowViewModel.cs | 18 ++- .../ViewModels/NodeViewModel.cs | 15 +- .../DragAndDropSample/Views/MainWindow.axaml | 25 +++- 5 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 samples/DragAndDropSample/Behaviors/BaseTreeViewDropHandler.cs diff --git a/samples/DragAndDropSample/Behaviors/BaseTreeViewDropHandler.cs b/samples/DragAndDropSample/Behaviors/BaseTreeViewDropHandler.cs new file mode 100644 index 00000000..5e2ae79b --- /dev/null +++ b/samples/DragAndDropSample/Behaviors/BaseTreeViewDropHandler.cs @@ -0,0 +1,135 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.DragAndDrop; + +namespace DragAndDropSample.Behaviors; + +public abstract class BaseTreeViewDropHandler : DropHandlerBase +{ + private const string rowDraggingUpStyleClass = "DraggingUp"; + private const string rowDraggingDownStyleClass = "DraggingDown"; + private const string targetHighlightStyleClass = "TargetHighlight"; + + protected abstract (bool Valid, bool WillSourceItemBeMovedToDifferentParent) Validate(TreeView tv, DragEventArgs e, object? sourceContext, object? targetContext, bool bExecute); + + public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + if (e.Source is Control && sender is TreeView tv) + { + var (valid, willSourceItemChangeParent) = Validate(tv, e, sourceContext, targetContext, false); + var targetVisual = tv.GetVisualAt(e.GetPosition(tv)); + if (valid) + { + var targetItem = FindTreeViewItemFromChildView(targetVisual); + // If its a movement within the same tree level, + // then an adorner layer will be applied. + + // But, if the source item will move to a different level, + // the level's owner will receive a background highlight. + + // In the case of being moved to a root target item, + // (with targetItem.Parent not being another TreeViewItem), + // then this root target item will receive this style. + var itemToApplyStyle = (willSourceItemChangeParent && targetItem?.Parent is TreeViewItem tviParent) ? + tviParent : targetItem; + string direction = e.Data.Contains("direction") ? (string)e.Data.Get("direction")! : "down"; + ApplyDraggingStyleToItem(itemToApplyStyle!, direction, willSourceItemChangeParent); + ClearDraggingStyleFromAllItems(sender, exceptThis: itemToApplyStyle); + } + return valid; + } + ClearDraggingStyleFromAllItems(sender); + return false; + } + + public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) + { + ClearDraggingStyleFromAllItems(sender); + if (e.Source is Control && sender is TreeView tv) + { + var (valid, _) = Validate(tv, e, sourceContext, targetContext, true); + return valid; + } + return false; + } + + public override void Cancel(object? sender, RoutedEventArgs e) + { + base.Cancel(sender, e); + // This is necessary to clear styles + // when mouse exists TreeView, else, + // they would remain even after changing screens. + ClearDraggingStyleFromAllItems(sender); + } + + private static TreeViewItem? FindTreeViewItemFromChildView(StyledElement? sourceChild) + { + if (sourceChild is null) + return null; + + int maxDepth = 16; + StyledElement? current = sourceChild; + while (maxDepth --> 0) + { + if (current is TreeViewItem tvi) + return tvi; + else + current = current?.Parent; + } + return null; + } + + private static void ClearDraggingStyleFromAllItems(object? sender, TreeViewItem? exceptThis = null) + { + if (sender is not Visual rootVisual) + return; + + foreach (var item in rootVisual.GetLogicalChildren().OfType()) + { + if (item == exceptThis) + continue; + + if (item.Classes is not null) + { + item.Classes.Remove(rowDraggingUpStyleClass); + item.Classes.Remove(rowDraggingDownStyleClass); + item.Classes.Remove(targetHighlightStyleClass); + } + ClearDraggingStyleFromAllItems(item, exceptThis); + } + } + + private static void ApplyDraggingStyleToItem(TreeViewItem? item, string direction, bool willSourceItemBeMovedToDifferentParent) + { + if (item is null) + return; + + // Avalonia's Classes.Add() verifies + // if a class has already been added + // (avoiding duplications); no need to + // verify .Contains() here. + if (willSourceItemBeMovedToDifferentParent) + { + item.Classes.Remove(rowDraggingDownStyleClass); + item.Classes.Remove(rowDraggingUpStyleClass); + item.Classes.Add(targetHighlightStyleClass); + } + else if (direction == "up") + { + item.Classes.Remove(rowDraggingDownStyleClass); + item.Classes.Remove(targetHighlightStyleClass); + item.Classes.Add(rowDraggingUpStyleClass); + } + else if (direction == "down") + { + item.Classes.Remove(rowDraggingUpStyleClass); + item.Classes.Remove(targetHighlightStyleClass); + item.Classes.Add(rowDraggingDownStyleClass); + } + } +} diff --git a/samples/DragAndDropSample/Behaviors/NodesTreeViewDropHandler.cs b/samples/DragAndDropSample/Behaviors/NodesTreeViewDropHandler.cs index e2598014..6b9531cc 100644 --- a/samples/DragAndDropSample/Behaviors/NodesTreeViewDropHandler.cs +++ b/samples/DragAndDropSample/Behaviors/NodesTreeViewDropHandler.cs @@ -1,27 +1,35 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.VisualTree; -using Avalonia.Xaml.Interactions.DragAndDrop; using DragAndDropSample.ViewModels; namespace DragAndDropSample.Behaviors; -public class NodesTreeViewDropHandler : DropHandlerBase +public class NodesTreeViewDropHandler : BaseTreeViewDropHandler { - private bool Validate(TreeView treeView, DragEventArgs e, object? sourceContext, object? targetContext, bool bExecute) where T : NodeViewModel + protected override (bool Valid, bool WillSourceItemBeMovedToDifferentParent) Validate(TreeView tv, DragEventArgs e, object? sourceContext, object? targetContext, bool bExecute) { - if (sourceContext is not T sourceNode + if (sourceContext is not NodeViewModel sourceNode || targetContext is not MainWindowViewModel vm - || treeView.GetVisualAt(e.GetPosition(treeView)) is not Control targetControl - || targetControl.DataContext is not T targetNode) + || tv.GetVisualAt(e.GetPosition(tv)) is not Control targetControl + || targetControl.DataContext is not NodeViewModel targetNode + || sourceNode == targetNode + || sourceNode.Parent == targetNode + || targetNode.IsDescendantOf(sourceNode) // block moving parent to inside child + || vm.HasMultipleTreeNodesSelected) { - return false; + // moving multiple items is disabled because + // when an item is clicked to be dragged (whilst pressing Ctrl), + // it becomes unselected and won't be considered for movement. + // TODO: find how to fix that. + return (false, false); } var sourceParent = sourceNode.Parent; var targetParent = targetNode.Parent; var sourceNodes = sourceParent is not null ? sourceParent.Nodes : vm.Nodes; var targetNodes = targetParent is not null ? targetParent.Nodes : vm.Nodes; + bool areSourceNodesDifferentThanTargetNodes = sourceNodes != targetNodes; if (sourceNodes is not null && targetNodes is not null) { @@ -30,7 +38,7 @@ private bool Validate(TreeView treeView, DragEventArgs e, object? sourceConte if (sourceIndex < 0 || targetIndex < 0) { - return false; + return (false, false); } switch (e.DragEffects) @@ -43,7 +51,7 @@ private bool Validate(TreeView treeView, DragEventArgs e, object? sourceConte InsertItem(targetNodes, clone, targetIndex + 1); } - return true; + return (true, areSourceNodesDifferentThanTargetNodes); } case DragDropEffects.Move: { @@ -51,17 +59,30 @@ private bool Validate(TreeView treeView, DragEventArgs e, object? sourceConte { if (sourceNodes == targetNodes) { - MoveItem(sourceNodes, sourceIndex, targetIndex); + if (sourceIndex < targetIndex) + { + sourceNodes.RemoveAt(sourceIndex); + sourceNodes.Insert(targetIndex, sourceNode); + } + else + { + int removeIndex = sourceIndex + 1; + if (sourceNodes.Count + 1 > removeIndex) + { + sourceNodes.RemoveAt(removeIndex - 1); + sourceNodes.Insert(targetIndex, sourceNode); + } + } } else { sourceNode.Parent = targetParent; - - MoveItem(sourceNodes, targetNodes, sourceIndex, targetIndex); + sourceNodes.RemoveAt(sourceIndex); + targetNodes.Add(sourceNode); // always adding to the end } } - return true; + return (true, areSourceNodesDifferentThanTargetNodes); } case DragDropEffects.Link: { @@ -80,29 +101,11 @@ private bool Validate(TreeView treeView, DragEventArgs e, object? sourceConte } } - return true; + return (true, areSourceNodesDifferentThanTargetNodes); } } } - return false; - } - - public override bool Validate(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) - { - if (e.Source is Control && sender is TreeView treeView) - { - return Validate(treeView, e, sourceContext, targetContext, false); - } - return false; - } - - public override bool Execute(object? sender, DragEventArgs e, object? sourceContext, object? targetContext, object? state) - { - if (e.Source is Control && sender is TreeView treeView) - { - return Validate(treeView, e, sourceContext, targetContext, true); - } - return false; + return (false, false); } } diff --git a/samples/DragAndDropSample/ViewModels/MainWindowViewModel.cs b/samples/DragAndDropSample/ViewModels/MainWindowViewModel.cs index eab18594..6a5f04b2 100644 --- a/samples/DragAndDropSample/ViewModels/MainWindowViewModel.cs +++ b/samples/DragAndDropSample/ViewModels/MainWindowViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Collections.Specialized; using ReactiveUI; namespace DragAndDropSample.ViewModels; @@ -20,6 +21,15 @@ public ObservableCollection Nodes set => this.RaiseAndSetIfChanged(ref _nodes, value); } + public ObservableCollection SelectedTreeNodes { get; } + + private bool _hasMultipleTreeNodesSelected; + public bool HasMultipleTreeNodesSelected + { + get => _hasMultipleTreeNodesSelected; + set => this.RaiseAndSetIfChanged(ref _hasMultipleTreeNodesSelected, value); + } + public MainWindowViewModel() { _items = new ObservableCollection() @@ -31,6 +41,9 @@ public MainWindowViewModel() new() { Title = "Item4" } }; + SelectedTreeNodes = new(); + SelectedTreeNodes.CollectionChanged += OnSelectedTreeNodesChanged; + var node0 = new NodeViewModel() { Title = "Node0" @@ -71,4 +84,7 @@ public MainWindowViewModel() node2 }; } -} \ No newline at end of file + + private void OnSelectedTreeNodesChanged(object? sender, NotifyCollectionChangedEventArgs e) => + HasMultipleTreeNodesSelected = SelectedTreeNodes.Count > 1; +} diff --git a/samples/DragAndDropSample/ViewModels/NodeViewModel.cs b/samples/DragAndDropSample/ViewModels/NodeViewModel.cs index 72ce6241..7d68d5d9 100644 --- a/samples/DragAndDropSample/ViewModels/NodeViewModel.cs +++ b/samples/DragAndDropSample/ViewModels/NodeViewModel.cs @@ -28,4 +28,17 @@ public ObservableCollection? Nodes } public override string? ToString() => _title; -} \ No newline at end of file + + public bool IsDescendantOf(NodeViewModel possibleAncestor) + { + var current = Parent; + while (current is not null) + { + if (current == possibleAncestor) + return true; + else + current = current.Parent; + } + return false; + } +} diff --git a/samples/DragAndDropSample/Views/MainWindow.axaml b/samples/DragAndDropSample/Views/MainWindow.axaml index e6bfcbb7..875d0f7b 100644 --- a/samples/DragAndDropSample/Views/MainWindow.axaml +++ b/samples/DragAndDropSample/Views/MainWindow.axaml @@ -90,12 +90,34 @@ - + + + + + + +