TreeViewを扱っているとアイテムをドラッグアンドドロップで移動させくなることがあります。
Formsでは結構やり方が乗ってるんですが、WPFとなると一切出てこないので実装してみることに。
本当はコードビハインドに書きたくはなかったのですが、こうする以外どうしようもなかったのでコードビハインドにゴリゴリ書き込みます。
Behavior使えばいいやん。
検証環境
項目 | 詳細 |
---|---|
OS | Windows 10 Pro x64 1903 |
.Net | .Net Core 3.1 |
Prism.Core | 7.2.0.1422 |
今回は試しに.Net Core 3.1でやってますが、元々は.Net Framework 4.8のものを移植してるので動くはず。
完成目標とサンプル
今回のサンプルを実装すると、こんな感じにTreeViewItemの移動が可能となります。
今回はTreeViewItemの移動だけになりますが、XMLなどの構造を表現する場合は同時にXmlNodeなどの調整も必要となります。
実際に使ってる例だと、SavannahManagerのXml Editorがありますが、本体のXMLノードであるSavannahXmlNodeの調整も同時に行っています。
なお、ブログではコードビハインドで書いていますが、あとあと不便になってきたのでSavannahManagerではBehaviorにして運用してます。
今回のサンプル全体は以下よりダウンロードなどができます。
コードビハインド版: TreeViewItemDragMove - GitHub
Behavior版: TreeViewItemDragMoveBehavior - GitHub
下準備 (表示してみる)
View
まずはTreeViewにただただ表示させてみます。
ItemTemplateではTextBlockなどちょこちょこ置いていますが、必須項目はSeparatorとGridのBackgroundのバインディングだけです。
あとは文字を表示したりするためだけに置いてるので好きにカスタマイズしてOKです。
また、ItemContainerStyleのIsExpandedやIsSelectedについてはWPF TreeViewで選択や展開を保持したまま再描写するをご覧ください。
<Window x:Class="TreeViewItemDragMove.Views.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:TreeViewItemDragMove.ViewModels" mc:Ignorable="d" Title="MainWindow" Height="450" Width="800"> <Grid> <TreeView Name="SampleTreeView" ItemsSource="{Binding SampleItems}"> <TreeView.ItemTemplate> <HierarchicalDataTemplate DataType="viewModels:TreeViewItemInfo" ItemsSource="{Binding Children}"> <Grid Background="{Binding Background}"> <StackPanel> <Separator BorderThickness="1" BorderBrush="Black" Visibility="{Binding BeforeSeparatorVisibility}" /> <TextBlock Text="{Binding Name}" MinWidth="60" /> <Separator BorderThickness="1" BorderBrush="Black" Visibility="{Binding AfterSeparatorVisibility}" /> </StackPanel> </Grid> </HierarchicalDataTemplate> </TreeView.ItemTemplate> <TreeView.ItemContainerStyle> <Style TargetType="{x:Type TreeViewItem}"> <Setter Property="IsExpanded" Value="{Binding Path=IsExpanded,Mode=TwoWay}"/> <Setter Property="IsSelected" Value="{Binding Path=IsSelected,Mode=TwoWay}"/> </Style> </TreeView.ItemContainerStyle> </TreeView> </Grid> </Window>
ViewModel
まずはTreeViewItemで使うデータタイプです。
ただ表示させるだけならほぼ使わない項目ばかりですが、後ほど使うのでとりあえず気にしない。
SetParentToChildrenメソッドに関してはサンプルの都合上Parentをスマートに設定することができなかったので作っただけで、データ読み込みの際に設定できるならそれでも可。
ViewModels/TreeViewItemInfo.cs
public class TreeViewItemInfo : BindableBase { #region Fields private string _name; private Brush _background = Brushes.Transparent; private bool _isExpanded; private bool _isSelected; private Visibility _beforeSeparatorVisibility = Visibility.Hidden; private Visibility _afterSeparatorVisibility = Visibility.Hidden; #endregion #region Properties public TreeViewItemInfo Parent { get; set; } public ObservableCollection<TreeViewItemInfo> Children { get; set; } = new ObservableCollection<TreeViewItemInfo>(); public string Name { get => _name; set => SetProperty(ref _name, value); } public Brush Background { get => _background; set => SetProperty(ref _background, value); } public bool IsExpanded { get => _isExpanded; set => SetProperty(ref _isExpanded, value); } public bool IsSelected { get => _isSelected; set => SetProperty(ref _isSelected, value); } public Visibility BeforeSeparatorVisibility { get => _beforeSeparatorVisibility; set => SetProperty(ref _beforeSeparatorVisibility, value); } public Visibility AfterSeparatorVisibility { get => _afterSeparatorVisibility; set => SetProperty(ref _afterSeparatorVisibility, value); } #endregion //-- 地震の親を指定されたTreeViewItemInfoオブジェクトにし、子要素の親を自身として設定します public void SetParentToChildren(TreeViewItemInfo parent = null) { Parent = parent; if (Children == null) return; foreach (var child in Children) { child.SetParentToChildren(this); } } //-- 既存の子要素アイテムの前に新しいアイテムを挿入します public void InsertBeforeChildren(TreeViewItemInfo from, TreeViewItemInfo newItem) { var index = Children.IndexOf(newItem); if (index < 0) return; Children.Insert(index, from); } //-- 既存の子要素アイテムの後ろに新しいアイテムを挿入します public void InsertAfterChildren(TreeViewItemInfo from, TreeViewItemInfo newItem) { var index = Children.IndexOf(newItem); if (index < 0) return; Children.Insert(index + 1, from); } //-- 子要素の末尾に新しいアイテムを追加します public void AddChildren(TreeViewItemInfo info) { Children.Add(info); } //-- 子要素から指定されたアイテムを削除します public void RemoveChildren(TreeViewItemInfo info) { Children.Remove(info); } //-- 親要素に指定されたアイテムが存在するかどうかをチェックします public bool ContainsParent(TreeViewItemInfo info) { if (Parent == null) return false; return Parent == info || Parent.ContainsParent(info); } }
お次はViewModelです。
込み入ったことをしないのでViewModelでデータ作ってますが、用途に合わせてModelに移したりしてください。
コンストラクタ最後に「dummy」を設定し、それをParentに設定するようにしていますが、最上位の項目たちはParentが存在しないので後のコードがうまく動かなくなります。
それを回避するためのダミールートとなります。
これに関してはもうちょっと煮詰めればうまく動くかもしれないけど、とりあえずこの方法じゃ無理そう。
ViewModels/MainWindowViewModel.cs
public class MainWindowViewModels { public ObservableCollection<TreeViewItemInfo> SampleItems { get; set; } public MainWindowViewModels() { SampleItems = new ObservableCollection<TreeViewItemInfo>(new [] { new TreeViewItemInfo { Name = "Item1"}, new TreeViewItemInfo { Name = "Item2", Children = new ObservableCollection<TreeViewItemInfo> { new TreeViewItemInfo { Name = "SubItem1"}, new TreeViewItemInfo { Name = "SubItem2", Children = new ObservableCollection<TreeViewItemInfo> { new TreeViewItemInfo { Name = "SubSubItem1" }, new TreeViewItemInfo { Name = "SubSubItem2" }, } }, new TreeViewItemInfo { Name = "SubItem3"} } }, new TreeViewItemInfo { Name = "Item3"}, new TreeViewItemInfo { Name = "Item4"}, new TreeViewItemInfo { Name = "Item5", Children = new ObservableCollection<TreeViewItemInfo> { new TreeViewItemInfo { Name = "SubItem1"}, new TreeViewItemInfo { Name = "SubItem2"}, new TreeViewItemInfo { Name = "SubItem3"} } } }); var dummy = new TreeViewItemInfo { Children = SampleItems }; foreach (var item in dummy.Children) { item.SetParentToChildren(dummy); } } }
コードビハインド
DataContextにViewModelを割り当てます。
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); DataContext = new MainWindowViewModels(); } }
下準備 (フィールドやイベントメソッドなどの定義)
ひたすらにややこしくなるので、可能な限り小分けします。
まずは必要なフィールドやイベントの定義周りを行います。
フィールドの定義
まずはフィールドを定義していきます。
InsertTypeですが、ドラッグ & ドロップの際にマウスカーソル上にあるアイテムの上か下に挿入するのか、あるいは子要素に追加するのかを判定するのに使います。
_changedBlocksは背景色やセパレータの表示を変更したTreeViewItemInfoオブジェクトを記憶するのに使います。
変更した背景色などは勝手に元に戻らないので都度元に戻してあげる必要があります。
_startPosは開始地点を記録します。
後ほど出てきますが、クリックがドラッグになってしまう問題を解決するのに使用します。
public partial class MainWindow : Window { private enum InsertType { After, Before, Children } private readonly HashSet<TreeViewItemInfo> _changedBlocks = new HashSet<TreeViewItemInfo>(); private InsertType _insertType; private Point? _startPos; public MainWindow() { InitializeComponent(); DataContext = new MainWindowViewModels(); } }
イベントメソッドの定義
イベントにはとりあえず空のメソッドを割り当てておきます。
AllowDropやイベントの宣言はXAML側で指定してもいいと思いますが、長くなるしどうせこっちに書くなら一緒にしちゃえってことでここに書きました。
基本的にsenderに対して処理を行うのでTreeViewに対して名前つけの必要もないしお好きな方で。
public partial class MainWindow : Window { ... public MainWindow() { InitializeComponent(); DataContext = new MainWindowViewModels(); SampleTreeView.AllowDrop = true; SampleTreeView.PreviewMouseLeftButtonDown += SampleTreeViewOnPreviewMouseLeftButtonDown; SampleTreeView.PreviewMouseLeftButtonUp += SampleTreeViewOnPreviewMouseLeftButtonUp; SampleTreeView.PreviewMouseMove += SampleTreeViewOnPreviewMouseMove; SampleTreeView.Drop += SampleTreeViewOnDrop; SampleTreeView.DragOver += SampleTreeViewOnDragOver; } private void SampleTreeViewOnDragOver(object sender, DragEventArgs e) { } private void SampleTreeViewOnDrop(object sender, DragEventArgs e) { } private void SampleTreeViewOnPreviewMouseMove(object sender, MouseEventArgs e) { } private void SampleTreeViewOnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { } private void SampleTreeViewOnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { } }
メソッド郡の定義
下準備が長くなってきましたが、これが最後です。
とにかくドラッグイベント内が強烈に長くなるので外に出せるものは全部静的メソッドとして定義しておきます。
○追記
VisualTreeHelper.HitTestメソッドよりも、UIElement#InputHitTestメソッドの方が安定するのでこちらに変更しました。
前者の問題点としては、親要素をクリックした後に子要素をクリックするとほぼ確定でTextBlockLineDrawingVisualが取得されるためTreeViewItemInfoが取得できくなります。
public partial class MainWindow : Window { ... //--- 親要素から子要素郡の末尾を取得します private static TreeViewItemInfo GetParentLastChild(TreeViewItemInfo info) { var targetParent = info.Parent; var last = targetParent?.Children.LastOrDefault(); return last; } //--- 親要素から指定した要素を削除します private static void RemoveCurrentItem(TreeViewItemInfo sourceItemParent, TreeViewItemInfo sourceItem) { sourceItemParent.RemoveChildren(sourceItem); } //--- 変更されたセパレータ、背景色を元に戻します private static void ResetSeparator(ICollection<TreeViewItemInfo> collection) { var list = collection.ToList(); foreach (var pair in list) { ResetSeparator(pair); collection.Remove(pair); } } //--- 背景色を元に戻します private static void ResetSeparator(TreeViewItemInfo info) { info.Background = Brushes.Transparent; info.BeforeSeparatorVisibility = Visibility.Hidden; info.AfterSeparatorVisibility = Visibility.Hidden; } //--- 上部・下部にドラッグした際にスクロールします private static void DragScroll(FrameworkElement itemsControl, DragEventArgs e) { var scrollViewer = itemsControl.Descendants<ScrollViewer>().FirstOrDefault(); const double tolerance = 10d; const double offset = 3d; var verticalPos = e.GetPosition(itemsControl).Y; if (verticalPos < tolerance) scrollViewer?.ScrollToVerticalOffset(scrollViewer.VerticalOffset - offset); else if (verticalPos > itemsControl.ActualHeight - tolerance) scrollViewer?.ScrollToVerticalOffset(scrollViewer.VerticalOffset + offset); } //--- カーソルポジションとUIElementからカーソル上の要素を取得します private static T HitTest<T>(UIElement itemsControl, Func<IInputElement, Point> getPosition) where T : class { var pt = getPosition(itemsControl); var result = itemsControl.InputHitTest(pt) as DependencyObject; if (result is T ret) return ret; return null; } //--- ドラッグ可能かどうかを判定します private static bool CanDrag(Vector delta) { return (SystemParameters.MinimumHorizontalDragDistance < Math.Abs(delta.X)) || (SystemParameters.MinimumVerticalDragDistance < Math.Abs(delta.Y)); } }
次に、子・孫要素を取得するコード(VisualTreeの子孫要素を取得するよりお借りしました)に親要素を取得する拡張メソッド、GetParentメソッドを追加したDependencyObjectExtensionsを定義しておきます。
Extensions/DependencyObjectExtensions.cs
public static class DependencyObjectExtensions { //--- 親要素を取得します public static T GetParent<T>(this DependencyObject obj) { var parent = VisualTreeHelper.GetParent(obj); return parent switch { null => default, T ret => ret, _ => parent.GetParent<T>() }; } //--- 子要素を取得します public static IEnumerable<DependencyObject> Children(this DependencyObject obj) { if (obj == null) throw new ArgumentNullException("obj"); var count = VisualTreeHelper.GetChildrenCount(obj); if (count == 0) yield break; for (int i = 0; i < count; i++) { var child = VisualTreeHelper.GetChild(obj, i); if (child != null) yield return child; } } //--- 子孫要素を取得します public static IEnumerable<DependencyObject> Descendants(this DependencyObject obj) { if (obj == null) throw new ArgumentNullException("obj"); foreach (var child in obj.Children()) { yield return child; foreach (var grandChild in child.Descendants()) yield return grandChild; } } //--- 特定の型の子要素を取得します public static IEnumerable<T> Children<T>(this DependencyObject obj) where T : DependencyObject { return obj.Children().OfType<T>(); } //--- 特定の型の子孫要素を取得します public static IEnumerable<T> Descendants<T>(this DependencyObject obj) where T : DependencyObject { return obj.Descendants().OfType<T>(); } }
イベントの実装
さて、ここからが本番です。
今まで下準備してきたものをふんだんに使用してTreeViewItemをドラッグしてやります。
PreviewMouseLeftButtonDown
PreviewMouseLeftButtonDownではドラッグの開始伴う下準備を行います。
基本的には_startPosにクリック時のPointoを入れるだけなんですが、それだけだとアイテムを選択した状態でTreeViewItem以外の部分を触ると反応してしまいます。(空の部分をクリックしてもフォーカスが外れないアレのせい)
そこで、カーソルのTreeViewからの相対座標でクリックした場所要素を取得し、DataContextがTreeViewItemInfoでない時はドラッグしないようにnullを入れるようにしておきます。
private void SampleTreeViewOnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (!(sender is ItemsControl itemsControl)) return; var pos = e.GetPosition(itemsControl); var hit = HitTest<FrameworkElement>(itemsControl, e.GetPosition); if (hit.DataContext is TreeViewItemInfo) _startPos = itemsControl.PointToScreen(pos); else _startPos = null; }
PreviewMouseMove
ここではマウスカーソルが移動した際にドラッグを開始するかどうかを判定します。
左クリック開始地点から現在のカーソル位置の差を計算し、CanDragで許容値内外を判定(要するにドラッグ可能かどうか)します。
ドラッグ可能ならそのままDragDrop.DoDragDropでドラッグアンドドロップ処理を開始します。
ドラッグ中はDragDrop.DoDragDropより下は待機するため、ドラッグ終了後に_startPosをnullにすることでドラッグ終了とします。
private void SampleTreeViewOnPreviewMouseMove(object sender, MouseEventArgs e) { if (!(sender is TreeView treeView) || treeView.SelectedItem == null || _startPos == null) return; var cursorPoint = treeView.PointToScreen(e.GetPosition(treeView)); var diff = cursorPoint - (Point) _startPos; if (!CanDrag(diff)) return; DragDrop.DoDragDrop(treeView, treeView.SelectedItem, DragDropEffects.Move); _startPos = null; }
PreviewMouseLeftButtonUp
後に出てくるPreviewMouseMoveの最後でもstartPosをnullにするようにしていますが、こちらはCanDragでFalseになった場合などの保険的な意味合いでstartPosをnullにしています。
ちなみに、DoDragDropを実行するとPreviewMouseLeftButtonUpは発生しません。なんでやねん。
private void SampleTreeViewOnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { _startPos = null; }
DragOver
さて、本題のドラッグアンドドロップ関連のイベントです。
まずはDragOverすなわち、ドラッグ中に発生するイベントです。
ここではドロップするアイテムをマウスカーソルの場所に挿入するのか、あるいは子要素に追加するのかを判定します。
...のですが、それまでに必要な要素を取得したりnullチェックしたりするのが超面倒なんですよ。
コード内で説明しないスタンスでしたが、そうでもしないと意味不明になるので今回はコメントで説明します。
private void SampleTreeViewOnDragOver(object sender, DragEventArgs e) { // 背景色やセパレータを元に戻します ResetSeparator(_changedBlocks); if (!(sender is ItemsControl itemsControl) || !e.Data.GetDataPresent(typeof(TreeViewItemInfo))) return; // 画面上部/下部にドラッグした際にスクロールします DragScroll(itemsControl, e); // ドラッグ中のアイテムとマウスカーソルの位置にある要素を取得します // HitTestで取れる要素は大体TextBlockなので、次でTreeViewItemInfoを取得します var sourceItem = (TreeViewItemInfo)e.Data.GetData(typeof(TreeViewItemInfo)); var targetElement = HitTest<FrameworkElement>(itemsControl, e.GetPosition); // カーソル要素から直近のGridを取得します(後の範囲計算で必要) // カーソル要素からTreeViewItemInfoを取得するにはDataContextを変換します // カーソル要素とドラッグ要素が同じ場合は何もする必要がないのでreturnしておきます var parentGrid = targetElement?.GetParent<Grid>(); if (parentGrid == null || !(targetElement.DataContext is TreeViewItemInfo targetElementInfo) || targetElementInfo == sourceItem) return; // カーソル要素がドラッグ中の要素の子要素にある時は何もする必要がないのでreturnします // 独自の処理をするならこれは不要、今回のコードではこれがないと要素が消えます if (targetElementInfo.ContainsParent(sourceItem)) return; e.Effects = DragDropEffects.Move; // 挿入するか子要素に追加するかの判定処理 // 基本的には0 ~ boundaryの位置なら上部に挿入、それ以外なら子要素に追加します // それだけでは末尾に追加できなくなるので子要素の最後だけ末尾に追加できるようにします const int boundary = 10; var pos = e.GetPosition(parentGrid); var targetParentLast = GetParentLastChild(targetElementInfo); if (pos.Y > 0 && pos.Y < boundary) { _insertType = InsertType.Before; targetElementInfo.BeforeSeparatorVisibility = Visibility.Visible; } else if (targetParentLast == targetElementInfo && pos.Y < parentGrid.ActualHeight && pos.Y > parentGrid.ActualHeight - boundary) { _insertType = InsertType.After; targetElementInfo.AfterSeparatorVisibility = Visibility.Visible; } else { _insertType = InsertType.Children; targetElementInfo.Background = Brushes.Gray; } // 背景色などを変更したTreeViewItemInfoオブジェクトを_changedBlocksに追加しておきます if (!_changedBlocks.Contains(targetElementInfo)) _changedBlocks.Add(targetElementInfo); }
Drop
最後にドロップ処理を書いて終了です。
ここまでくるとあとはドラッグ中のTreeViewItemInfoオブジェクトとマウス位置のTreeViewItemInfoオブジェクトを取得し、そこから親要素に対していじいじしてあげれば移動処理は完了です。
DragOverよりは圧倒的にわかりやすいかな・・・。
private void SampleTreeViewOnDrop(object sender, DragEventArgs e) { // 背景色やセパレータを元に戻します ResetSeparator(_changedBlocks); if (!(sender is ItemsControl itemsControl)) return; // ドラッグ中の要素(source)とマウス位置の要素(target)を取得します var sourceItem = (TreeViewItemInfo) e.Data.GetData(typeof(TreeViewItemInfo)); var targetItem = HitTest<FrameworkElement>(itemsControl, e.GetPosition)?.DataContext as TreeViewItemInfo; // それぞれの要素がnullならreturn // もしくはsourceとtargetが同一の場合もreturn if (targetItem == null || sourceItem == null || sourceItem == targetItem) return; // カーソル要素がドラッグ中の要素の子要素にある時は何もする必要がないのでreturnします if (targetItem.ContainsParent(sourceItem)) return; // それぞれの要素の親要素を取得しておきます // 次にsourceを現在の位置から削除しておきます // あとはBefore, Afterの場合はtargetの前後にsourceを挿入 // Childrenの場合はtargetの子要素に追加します var targetItemParent = targetItem.Parent; var sourceItemParent = sourceItem.Parent; RemoveCurrentItem(sourceItemParent, sourceItem); switch (_insertType) { case InsertType.Before: targetItemParent.InsertBeforeChildren(sourceItem, targetItem); sourceItem.Parent = targetItemParent; sourceItem.IsSelected = true; break; case InsertType.After: targetItemParent.InsertAfterChildren(sourceItem, targetItem); sourceItem.Parent = targetItemParent; sourceItem.IsSelected = true; break; default: targetItem.AddChildren(sourceItem); targetItem.IsExpanded = true; sourceItem.IsSelected = true; sourceItem.Parent = targetItem; break; } }
参考文献
WPF Listbox auto scroll while dragging
VisualTreeの子孫要素を取得する
[WPF] ListBoxでのドラッグ&ドロップ その1