for WPF developers
Home Profile Tips 全記事一覧

ListBox などのアイテムをドラッグ&ドロップ操作で並べ替える

(2017/06/02 9:25:52 created.)

二番煎じもいいところのお題ではありますが、実はこの手の情報を検索すると、そのほとんどが View 側でコレクションを並べ替えるものです。
コレクションを提供しているのは ViewModel 側で、その並び順を制御するのも ViewModel 側の責務だと私は思っていたので、これといった良いサンプルコードに出会うことができませんでした。

というわけでいつも通り、無い物は自作すればいいじゃない、の精神でやってみます。

意図したサンプルコードは見当たりませんでしたが、周辺コードはとても参考になりました。
例えば ItemsControl に並べられているアイテムをクリックしたときなど、イベント引数の OriginalSource プロパティを利用することでどのアイテムが対象であるかを知ることができます。

また、ItemsControl.ItemContainerGenerator プロパティを使用することで、対象とするコンテナが何番目のものなのか知ることができます。

以上の方法を使うことで、次のような流れでドラッグ&ドロップ操作の結果を ViewModel 側に伝えます。

  1. MouseLeftButtonDown イベントで対象のアイテムを確定
  2. MouseMove イベントでドラッグ開始判定
  3. Drop イベントでドロップ先のインデックス番号をコールバックする
この他に、MouseLeftButtonUp や DragEnter、DragLeave イベントでエラー対処などをすることになります。

View と ViewModel を準備

まずどのような UI を使用するかを紹介します。

MainView.xaml
  1. <Window x:Class="Tips_ReorderedListBox.Views.MainView"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         Title="MainView" Height="300" Width="300">
  5.     <StackPanel>
  6.         <ListBox ItemsSource="{Binding Items}" SelectedIndex="{Binding CurrentIndex}" />
  7.     </StackPanel>
  8. </Window>
MainViewModel.cs
  1. namespace Tips_ReorderedListBox.ViewModels
  2. {
  3.     using System.Collections.ObjectModel;
  4.     using System.ComponentModel;
  5.     using System.Runtime.CompilerServices;
  6.  
  7.     internal class MainViewModel : INotifyPropertyChanged
  8.     {
  9.         private ObservableCollection<string> _items = new ObservableCollection<string>()
  10.         {
  11.             "アイテム 1",
  12.             "アイテム 2",
  13.             "アイテム 3",
  14.             "アイテム 4",
  15.             "アイテム 5",
  16.         };
  17.         public ObservableCollection<string> Items { get { return this._items; } }
  18.  
  19.         private int _currentIndex;
  20.         public int CurrentIndex
  21.         {
  22.             get { return this._currentIndex; }
  23.             set { SetProperty(ref this._currentIndex, value); }
  24.         }
  25.  
  26.         #region INotifyPropertyChanged のメンバ
  27.  
  28.         public event PropertyChangedEventHandler PropertyChanged;
  29.  
  30.         private void RaisePropertyChanged([CallerMemberName]string propertyName = null)
  31.         {
  32.             var h = this.PropertyChanged;
  33.             if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
  34.         }
  35.  
  36.         private bool SetProperty<T>(ref T target, T value, [CallerMemberName]string propertyName = null)
  37.         {
  38.             if (Equals(target, value)) return false;
  39.             target = value;
  40.             RaisePropertyChanged(propertyName);
  41.             return true;
  42.         }
  43.  
  44.         #endregion INotifyPropertyChanged のメンバ
  45.     }
  46. }

起動するとこんな感じ。

「アイテム 1」や「アイテム 2」をドラッグ&ドロップで並べ替えられるようにすることが目的です。

ReorderableItemsControlBehavior クラスを作成する

それでは本題に入ります。ドラッグ&ドロップに関するコードをビヘイビアに詰め込むために、ReorderableItemsControlBehavior というクラスを新規作成します。

ReorderableItemsControlBehavior.cs
  1. namespace Tips_ReorderedListBox.Views.Behaviors
  2. {
  3.     using System;
  4.     using System.Windows;
  5.     using System.Windows.Controls;
  6.     using System.Windows.Input;
  7.  
  8.     /// <summary>
  9.     /// ItemsControl に対するドラッグ&ドロップによる並べ替え動作をおこなうビヘイビアを表します。
  10.     /// </summary>
  11.     internal class ReorderableItemsControlBehavior
  12.     {
  13.         #region Callback 添付プロパティ
  14.  
  15.         /// <summary>
  16.         /// Callback 添付プロパティの定義
  17.         /// </summary>
  18.         public static readonly DependencyProperty CallbackProperty = DependencyProperty.RegisterAttached("Callback", typeof(Action<int>), typeof(ReorderableItemsControlBehavior), new PropertyMetadata(null, OnCallbackPropertyChanged));
  19.  
  20.         /// <summary>
  21.         /// Callback 添付プロパティを取得します。
  22.         /// </summary>
  23.         /// <param name="target">対象とする DependencyObject を指定します。</param>
  24.         /// <returns>取得した値を返します。</returns>
  25.         public static Action<int> GetCallback(DependencyObject target)
  26.         {
  27.             return (Action<int>)target.GetValue(CallbackProperty);
  28.         }
  29.  
  30.         /// <summary>
  31.         /// Callback 添付プロパティを設定します。
  32.         /// </summary>
  33.         /// <param name="target">対象とする DependencyObject を指定します。</param>
  34.         /// <param name="value">設定する値を指定します。</param>
  35.         public static void SetCallback(DependencyObject target, Action<int> value)
  36.         {
  37.             target.SetValue(CallbackProperty, value);
  38.         }
  39.  
  40.         /// <summary>
  41.         /// Callback 添付プロパティ変更イベントハンドラ
  42.         /// </summary>
  43.         /// <param name="d">イベント発行元</param>
  44.         /// <param name="e">イベント引数</param>
  45.         private static void OnCallbackPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  46.         {
  47.             var itemsControl = d as ItemsControl;
  48.             if (itemsControl == null) return;
  49.  
  50.             if (GetCallback(itemsControl) != null)
  51.             {
  52.                 itemsControl.PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown;
  53.                 itemsControl.PreviewMouseMove += OnPreviewMouseMove;
  54.                 itemsControl.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp;
  55.                 itemsControl.PreviewDragEnter += OnPreviewDragEnter;
  56.                 itemsControl.PreviewDragLeave += OnPreviewDragLeave;
  57.                 itemsControl.PreviewDrop += OnPreviewDrop;
  58.             }
  59.             else
  60.             {
  61.                 itemsControl.PreviewMouseLeftButtonDown -= OnPreviewMouseLeftButtonDown;
  62.                 itemsControl.PreviewMouseMove -= OnPreviewMouseMove;
  63.                 itemsControl.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp;
  64.                 itemsControl.PreviewDragEnter -= OnPreviewDragEnter;
  65.                 itemsControl.PreviewDragLeave -= OnPreviewDragLeave;
  66.                 itemsControl.PreviewDrop -= OnPreviewDrop;
  67.             }
  68.         }
  69.  
  70.         #endregion Callback 添付プロパティ
  71.  
  72.         #region イベントハンドラ
  73.  
  74.         /// <summary>
  75.         /// PreviewMouseLeftButtonDown イベントハンドラ
  76.         /// </summary>
  77.         /// <param name="sender">イベント発行元</param>
  78.         /// <param name="e">イベント引数</param>
  79.         private static void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
  80.         {
  81.             throw new NotImplementedException();
  82.         }
  83.  
  84.         /// <summary>
  85.         /// PreviewMouseMove イベントハンドラ
  86.         /// </summary>
  87.         /// <param name="sender">イベント発行元</param>
  88.         /// <param name="e">イベント引数</param>
  89.         private static void OnPreviewMouseMove(object sender, MouseEventArgs e)
  90.         {
  91.             throw new NotImplementedException();
  92.         }
  93.  
  94.         /// <summary>
  95.         /// PreviewMouseLeftButtonUp イベントハンドラ
  96.         /// </summary>
  97.         /// <param name="sender">イベント発行元</param>
  98.         /// <param name="e">イベント引数</param>
  99.         private static void OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
  100.         {
  101.             throw new NotImplementedException();
  102.         }
  103.  
  104.         /// <summary>
  105.         /// PreviewDragEnter イベントハンドラ
  106.         /// </summary>
  107.         /// <param name="sender">イベント発行元</param>
  108.         /// <param name="e">イベント引数</param>
  109.         private static void OnPreviewDragEnter(object sender, DragEventArgs e)
  110.         {
  111.             throw new NotImplementedException();
  112.         }
  113.  
  114.         /// <summary>
  115.         /// PreviewDragLeave イベントハンドラ
  116.         /// </summary>
  117.         /// <param name="sender">イベント発行元</param>
  118.         /// <param name="e">イベント引数</param>
  119.         private static void OnPreviewDragLeave(object sender, DragEventArgs e)
  120.         {
  121.             throw new NotImplementedException();
  122.         }
  123.  
  124.         /// <summary>
  125.         /// PreviewDrop イベントハンドラ
  126.         /// </summary>
  127.         /// <param name="sender">イベント発行元</param>
  128.         /// <param name="e">イベント引数</param>
  129.         private static void OnPreviewDrop(object sender, DragEventArgs e)
  130.         {
  131.             throw new NotImplementedException();
  132.         }
  133.  
  134.         #endregion イベントハンドラ
  135.     }
  136. }

このビヘイビアでは最終的にコールバックで ViewModel 側に操作結果を伝えることが目的なので、安直に Callback 添付プロパティをあらかじめ定義しておきます。ドラッグ&ドロップ操作によって移動先のインデックス番号がわかればいいので、Action<int> 型としています。

また、このビヘイビアは ItemsControl 派生のコントロールクラスを想定しているため、Callback 添付プロパティが設定されたときに ItemsControl クラスとして各種マウスイベントに対するイベントハンドラを登録します。ここではまだスタブを定義しているだけで、実際の実装内容はこれから順番に説明します。

ドラッグ中の一時データ

各イベントハンドラの実装の前に、ドラッグ中の一時データを保持するためのクラスをひとつだけ用意します。

DragDropObject クラス
  1. /// <summary>
  2. /// ドラッグ中の一時データ
  3. /// </summary>
  4. private static DragDropObject temporaryData;
  5.  
  6. /// <summary>
  7. /// ドラッグ&ドロップに関するデータを表します。
  8. /// </summary>
  9. private class DragDropObject
  10. {
  11.     /// <summary>
  12.     /// ドラッグ開始座標を取得または設定します。
  13.     /// </summary>
  14.     public Point Start { get; set; }
  15.  
  16.     /// <summary>
  17.     /// ドラッグ対象であるオブジェクトを取得または設定します。
  18.     /// </summary>
  19.     public FrameworkElement DraggedItem { get; set; }
  20.  
  21.     /// <summary>
  22.     /// ドロップ可能かどうかを取得または設定します。
  23.     /// </summary>
  24.     public bool IsDroppable { get; set; }
  25.  
  26.     /// <summary>
  27.     /// ドラッグを開始していいかどうかを確認します。
  28.     /// </summary>
  29.     /// <param name="current">現在のマウス座標を指定します。</param>
  30.     /// <returns>十分マウスが移動している場合に true を返します。</returns>
  31.     public bool CheckStartDragging(Point current)
  32.     {
  33.         return (current - this.Start).Length - MinimumDragPoint.Length > 0;
  34.     }
  35.  
  36.     /// <summary>
  37.     /// ドラッグ開始に必要な最短距離を示すベクトル
  38.     /// </summary>
  39.     private static readonly Vector MinimumDragPoint = new Vector(SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance);
  40. }

PreviewMouseLeftButtonDown イベントハンドラ

ドラッグ&ドロップ操作の入り口となるマウスボタンを押したときのイベントハンドラを次のように実装します。

PreviewMouseLeftButtonDown イベントハンドラ
  1. /// <summary>
  2. /// PreviewMouseLeftButtonDown イベントハンドラ
  3. /// </summary>
  4. /// <param name="sender">イベント発行元</param>
  5. /// <param name="e">イベント引数</param>
  6. private static void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
  7. {
  8.     var control = sender as FrameworkElement;
  9.     temporaryData = new DragDropObject();
  10.     temporaryData.Start = e.GetPosition(Window.GetWindow(control));
  11.     temporaryData.DraggedItem = GetTemplatedRootElement(e.OriginalSource as FrameworkElement);
  12. }

GetTemplatedRootElement() メソッドでは、与えられた要素の TemplatedParent をたどって得られるルート要素を取得するためのヘルパメソッドで、次のように実装しています。

GetTemplatedRootElement ヘルパメソッド
  1. /// <summary>
  2. /// 指定された FrameworkElement に対するテンプレートのルート要素を取得します。
  3. /// </summary>
  4. /// <param name="element">FrameworkElement を指定します。</param>
  5. /// <returns>TemplatedParent を辿った先のルート要素を返します。</returns>
  6. private static FrameworkElement GetTemplatedRootElement(FrameworkElement element)
  7. {
  8.     var parent = element.TemplatedParent as FrameworkElement;
  9.     while (parent.TemplatedParent != null)
  10.     {
  11.         parent = parent.TemplatedParent as FrameworkElement;
  12.     }
  13.     return parent;
  14. }

e.GetPosition() メソッドでマウスの相対位置取得できます。ここでは Window.GetWindow() メソッドを利用することで、アプリケーションのウィンドウを基準とした相対位置を取得し、ドラッグの開始位置を保持しています。

e.OriginalSource はイベント発行元の要素なので、これを利用してドラッグするオブジェクトを保持します。ただし、e.OriginalSource から得られる要素は例えば Border コントロールだったり TextBlock コントロールだったりと、ItemsControl の各子要素を構成するコントロールの一部になります。ここでは、そこから得られる子要素のコンテナ自体を取得したいため、TemplatedParent プロパティをたどってコンテナ要素を探索しています。こうすることで、今回のように ListBox コントロールを対象とする場合はこのメソッドで ListBoxItem コントロールが得られます。

PreviewMouseMove イベントハンドラ

PreviewMouseLeftButtonDown イベントハンドラでドラッグ対象のオブジェクトを掴んだら、次はマウスを動かすときの挙動を処理します。

PreviewMouseMove イベントハンドラ
  1. /// <summary>
  2. /// PreviewMouseMove イベントハンドラ
  3. /// </summary>
  4. /// <param name="sender">イベント発行元</param>
  5. /// <param name="e">イベント引数</param>
  6. private static void OnPreviewMouseMove(object sender, MouseEventArgs e)
  7. {
  8.     if (temporaryData != null)
  9.     {
  10.         var control = sender as FrameworkElement;
  11.         var current = e.GetPosition(Window.GetWindow(control));
  12.         if (temporaryData.CheckStartDragging(current))
  13.         {
  14.             DragDrop.DoDragDrop(control, temporaryData.DraggedItem, DragDropEffects.Move);
  15.             // この先は Drop イベント処理後におこなわれる
  16.             temporaryData = null;
  17.         }
  18.     }
  19. }

PreviewMouseMove イベントは、マウスがこのコントロール上を通過するだけで発生してしまうため、ドラッグせずにただマウスが通過してもこのイベントハンドラが処理されてしまいます。このため、temporaryData オブジェクトが null かどうかを判定することで、事前に PreviewMouseLeftButtonDown イベントハンドラが処理されたかどうかを判別しています。

CheckStartDragging() メソッドで十分な距離を移動したとき、DragDrop.DoDragDrop() メソッドを呼び出していよいよドラッグ操作を開始します。このメソッドを呼び出すと、ここの処理をいったん抜け出し、以降の処理は Drop イベントが発生した後に実行されることになります。Drop イベント発生後は、temporaryData が不要になるので、null を入れてオブジェクトを破棄します。

PreviewMouseLeftButtonUp イベントハンドラ

マウスボタンを押したものの、動かさずにそのままボタンを離した場合、特に処理をせずに終了させます。

PreviewMouseLeftButtonUp イベントハンドラ
  1. /// <summary>
  2. /// PreviewMouseLeftButtonUp イベントハンドラ
  3. /// </summary>
  4. /// <param name="sender">イベント発行元</param>
  5. /// <param name="e">イベント引数</param>
  6. private static void OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
  7. {
  8.     temporaryData = null;
  9. }

PreviewDragEnter イベントハンドラ

DragDrop.DoDragDrop() メソッドがコールされた後は、ドラッグ&ドロップ系のイベントが発生するようになります。DragEnter イベントは対象となるコントロールの外側から内側に入り込むときに一度だけ発生します。

PreviewDragEnter イベントハンドラ
  1. /// <summary>
  2. /// PreviewDragEnter イベントハンドラ
  3. /// </summary>
  4. /// <param name="sender">イベント発行元</param>
  5. /// <param name="e">イベント引数</param>
  6. private static void OnPreviewDragEnter(object sender, DragEventArgs e)
  7. {
  8.     temporaryData.IsDroppable = true;
  9. }

コントロールの外側でドロップされたときに「何も処理しない」という処理をするため、IsDroppable プロパティを制御しています。

PreviewDragLeave イベントハンドラ

DragLeave イベントは対象となるコントロールの内側から外側に出るときに一度だけ発生します。コントロールの内側でドロップされたときに本題の処理をおこなうため、IsDroppable プロパティを制御しています。

PreviewDragLeave イベントハンドラ
  1. /// <summary>
  2. /// PreviewDragLeave イベントハンドラ
  3. /// </summary>
  4. /// <param name="sender">イベント発行元</param>
  5. /// <param name="e">イベント引数</param>
  6. private static void OnPreviewDragLeave(object sender, DragEventArgs e)
  7. {
  8.     temporaryData.IsDroppable = false;
  9. }

PreviewDrop イベントハンドラ

ようやく本題の処理の部分です。ドロップされたときにそのドロップ位置を ViewModel 側にコールバックする処理を実装しています。

PreviewDrop イベントハンドラ
  1. /// <summary>
  2. /// PreviewDrop イベントハンドラ
  3. /// </summary>
  4. /// <param name="sender">イベント発行元</param>
  5. /// <param name="e">イベント引数</param>
  6. private static void OnPreviewDrop(object sender, DragEventArgs e)
  7. {
  8.     if (temporaryData.IsDroppable)
  9.     {
  10.         var itemsControl = sender as ItemsControl;
  11.         // 異なる ItemsControl 間でドロップ処理されないようにするために
  12.         // 同一 ItemsControl 内にドラッグされたコンテナが存在することを確認する
  13.         if (itemsControl.ItemContainerGenerator.IndexFromContainer(temporaryData.DraggedItem) >= 0)
  14.         {
  15.             var targetContainer = GetTemplatedRootElement(e.OriginalSource as FrameworkElement);
  16.             var index = itemsControl.ItemContainerGenerator.IndexFromContainer(targetContainer);
  17.             if (index >= 0)
  18.             {
  19.                 var callback = GetCallback(itemsControl);
  20.                 callback(index);
  21.             }
  22.         }
  23.     }
  24.  
  25.     // 終了後は DragDrop.DoDragDrop() メソッド呼び出し元へ戻る
  26. }

やりたかったことは、16 行目と 20 行目の 2 行だけです。

16 行目でドロップされたアイテムのインデックス番号を取得しています。そして、20 行目で ViewModel 側から指定されるであろうコールバックメソッドのデリゲートを呼び出しています。

その他に、変なところでドロップされたとき、index が -1 となることがあるため、ここではそのような場合は何もしないようにしています。場合によってはそんなときは必ず最後尾のインデックス番号を返すようにして見てもいいかもしれません。

また、異なる ItemsControl のコントロールが複数あって、そのどれもがこのビヘイビアを実装した場合、隣の ItemsControl のアイテムがドラッグされても同様の処理が実行されてしまわないように、同一のコントロール内だけで処理がおこなわれるように 13 行目の判定を追加しています。ドラッグしたコンテナを含んでいるかどうかを探索することでドラッグ元の ItemsControl とドロップ先の ItemsControl が同一のコントロールであるかどうかを判定しています。

使ってみよう

それでは実際に使ってみます。MainView.xaml と MainViewModel.cs を次のように変更します。

MainView.xaml
  1. <Window x:Class="Tips_ReorderedListBox.Views.MainView"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:b="clr-namespace:Tips_ReorderedListBox.Views.Behaviors"
  5.         Title="MainView" Height="300" Width="300">
  6.     <StackPanel>
  7.         <ListBox ItemsSource="{Binding Items}" SelectedIndex="{Binding CurrentIndex}" b:ReorderableItemsControlBehavior.Callback="{Binding DropCallback}" AllowDrop="True" />
  8.     </StackPanel>
  9. </Window>
MainViewModel.cs
  1. namespace Tips_ReorderedListBox.ViewModels
  2. {
  3.     using System;
  4.     using System.Collections.ObjectModel;
  5.     using System.ComponentModel;
  6.     using System.Runtime.CompilerServices;
  7.  
  8.     internal class MainViewModel : INotifyPropertyChanged
  9.     {
  10.         private ObservableCollection<string> _items = new ObservableCollection<string>()
  11.         {
  12.             "アイテム 1",
  13.             "アイテム 2",
  14.             "アイテム 3",
  15.             "アイテム 4",
  16.             "アイテム 5",
  17.         };
  18.         public ObservableCollection<string> Items { get { return this._items; } }
  19.  
  20.         private int _currentIndex;
  21.         public int CurrentIndex
  22.         {
  23.             get { return this._currentIndex; }
  24.             set { SetProperty(ref this._currentIndex, value); }
  25.         }
  26.  
  27.         public Action<int> DropCallback { get { return OnDrop; } }
  28.  
  29.         private void OnDrop(int index)
  30.         {
  31.             if (index >= 0)
  32.             {
  33.                 this.Items.Move(this.CurrentIndex, index);
  34.             }
  35.         }
  36.  
  37.         #region INotifyPropertyChanged のメンバ
  38.  
  39.         public event PropertyChangedEventHandler PropertyChanged;
  40.  
  41.         private void RaisePropertyChanged([CallerMemberName]string propertyName = null)
  42.         {
  43.             var h = this.PropertyChanged;
  44.             if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
  45.         }
  46.  
  47.         private bool SetProperty<T>(ref T target, T value, [CallerMemberName]string propertyName = null)
  48.         {
  49.             if (Equals(target, value)) return false;
  50.             target = value;
  51.             RaisePropertyChanged(propertyName);
  52.             return true;
  53.         }
  54.  
  55.         #endregion INotifyPropertyChanged のメンバ
  56.     }
  57. }

実行結果は次のようになります。

View からはあくまでもドロップ先のインデックス番号が通知されるだけで、ViewModel 側は現在のインデックス番号と通知されたインデックス番号を使って自分が持っているコレクションを操作しています。

おまけ

このビヘイビアの旨味は TabControl に対しても使えるところ。MainView.xaml だけを次のように変更しても使えます。

MainView.xaml
  1. <Window x:Class="Tips_ReorderedListBox.Views.MainView"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:b="clr-namespace:Tips_ReorderedListBox.Views.Behaviors"
  5.         Title="MainView" Height="300" Width="300">
  6.     <StackPanel>
  7.         <TabControl ItemsSource="{Binding Items}" SelectedIndex="{Binding CurrentIndex}" b:ReorderableItemsControlBehavior.Callback="{Binding DropCallback}" AllowDrop="True" />
  8.     </StackPanel>
  9. </Window>

ListBox を TabControl に変更しただけです。

実行結果。ちゃんとドラッグ&ドロップで並べ替えられています。