tips - ドラッグ&ドロップ操作によるリストの並べ替え

 マウスによるドラッグ&ドロップ操作によってアイテムを並べ替えることができるリストを ビヘイビアで実現する一例を紹介します。

 サンプルプロジェクトはここからダウンロードできます。

ページ内リンク

概要

 いきなり結論からいうと、ドラッグ&ドロップによる操作は アプリケーションごとに仕様が異なるため、 汎用的なビヘイビアを作ろうと思うとかなり大変な作業となります。 ここでは、ItemsControl 派生コントロールに対して ItemsSource プロパティではなく、Items プロパティに対してアイテムを指定した場合の ドラッグ&ドロップ操作を実現するビヘイビアを紹介します。

ドラッグ&ドロップ操作のおおまかな流れをイベントの観点から整理すると次のようになります。

  • PreviewMouseLeftButtonDown イベント (マウス左ボタン)で掴むアイテムを捕捉する
  • PreviewMouseMove イベントでアイテムを掴んだままマウスが動いたことを確認してドラッグ操作へ移行する
  • PreviewDragOver イベントでコントロール上でドラッグ操作されているときの処理をおこなう
  • PreviewDragLeave イベントでコントロール外にはみ出たときの処理をおこなう
  • PreviewDragEnter イベントでコントロール外からコントロール内に入ったときの処理をおこなう
  • PreviewDrop イベントでドロップされたときの処理をおこなう
  • PreviewMouseUp イベントで単純にクリック操作されたときの処理をおこなう
見てのとおりかなり長いですが、ひとつひとつ見ていきましょう。

 まず、ビヘイビア名を ItemDragDropBehavior として、 大枠として次のようなビヘイビアを作成します。

namespace WPF_MVVM_Template2.Views.Behaviors
{
    using System;
    using System.Collections;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Input;

    public class ItemDragDropBehavior
    {
        #region IsEnabled 添付プロパティ
        public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached("IsEnabled", typeof(bool), typeof(ItemDragDropBehavior), new FrameworkPropertyMetadata(false, OnIsEnabledChanged));
        public static bool GetIsEnabled(DependencyObject target)
        {
            return (bool)target.GetValue(IsEnabledProperty);
        }
        public static void SetIsEnabled(DependencyObject target, bool value)
        {
            target.SetValue(IsEnabledProperty, value);
        }

        /// <summary>
        /// IsEnabled 添付プロパティ値変更イベントハンドラ
        /// </summary>
        /// <param name="d">イベント発行元</param>
        /// <param name="e">イベント引数</param>
        private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var control = d as ItemsControl;
            // ItemDragDropBehavior ビヘイビアは ItemsControl 派生コントロールを対象としている
            if (control != null)
            {
                if (GetIsEnabled(control))
                {
                    // IsEnabled プロパティが true のときにイベントハンドラを登録する
                    control.PreviewMouseLeftButtonDown += control_PreviewMouseLeftButtonDown;
                    control.PreviewMouseMove += control_PreviewMouseMove;
                    control.PreviewDragEnter += control_PreviewDragEnter;
                    control.PreviewDragOver += control_PreviewDragOver;
                    control.PreviewDragLeave += control_PreviewDragLeave;
                    control.PreviewDrop += control_PreviewDrop;
                    control.PreviewMouseUp += control_PreviewMouseUp;
                }
                else
                {
                    // IsEnabled プロパティが false のときにイベントハンドラを登録解除する
                    control.PreviewMouseLeftButtonDown -= control_PreviewMouseLeftButtonDown;
                    control.PreviewMouseMove -= control_PreviewMouseMove;
                    control.PreviewDragEnter -= control_PreviewDragEnter;
                    control.PreviewDragOver -= control_PreviewDragOver;
                    control.PreviewDragLeave -= control_PreviewDragLeave;
                    control.PreviewDrop -= control_PreviewDrop;
                    control.PreviewMouseUp -= control_PreviewMouseUp;
                }
            }
        }
        #endregion IsEnabled 添付プロパティ

        #region private static フィールド
        /// <summary>
        /// マウス左ボタンによってこのビヘイビアによるドラッグ&ドロップ操作シーケンスに移行したことを確認するためのフラグ
        /// </summary>
        private static bool _isMouseDown;

        /// <summary>
        /// ドラッグされているアイテムに対する論理親 (このビヘイビアが複数のコントロールに適用されることを考慮している)
        /// </summary>
        private static ItemsControl _parent;

        /// <summary>
        /// ドラッグされているアイテム
        /// </summary>
        private static object _draggedItem;

        /// <summary>
        /// ドラッグされているアイテムのインデックス
        /// </summary>
        private static int _draggedItemIndex;

        /// <summary>
        /// アイテムを掴むときのマウスポインタの位置
        /// </summary>
        private static Point _originPoint;
        #endregion private static フィールド

        #region イベントハンドラ
        private static void control_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
        }

        private static void control_PreviewMouseMove(object sender, MouseEventArgs e)
        {
        }

        private static void control_PreviewDragEnter(object sender, DragEventArgs e)
        {
        }

        private static void control_PreviewDragOver(object sender, DragEventArgs e)
        {
        }

        private static void control_PreviewDragLeave(object sender, DragEventArgs e)
        {
        }

        private static void control_PreviewDrop(object sender, DragEventArgs e)
        {
        }

        private static void control_PreviewMouseUp(object sender, MouseButtonEventArgs e)
        {
        }
        #endregion イベントハンドラ
    }
}
Code 1 : ItemDragDropBehavior の骨格
いきなり長いですが、順番に見ていきましょう。

 まず始めの方で IsEnabled 添付プロパティを定義しています。 既定値を false としていて、XAML 上でこれを true として指定したときにこのビヘイビアの機能が有効になるようにしています。 IsEnabled 添付プロパティの変更イベントハンドラとして OnIsEnabledChanged() という静的メソッドを指定しています。 このやり方はビヘイビア作成の定石ですね。

 OnIsEnabledChanged() メソッドの中では、次の 2 点を確認しています。

  • イベント発行元が ItemsControl 派生コントロールであること
  • IsEnabled 添付プロパティが true/false であること
このビヘイビアは ItemsControl 派生コントロールを対象としているため、 そうでないコントロールに対しては何も処理せずに終了しています。

 イベント発行元が ItemsControl 派生コントロールである場合、 そのコントロールに対する IsEnabled 添付プロパティが true であれば それぞれのイベントに対してイベントハンドラを登録し、 false であればイベントハンドラを登録解除するようにしています。

 IsEnabled 添付プロパティの既定値が false のため、 OnIsEnabledChanged() メソッドが動くのは true になったときですが、 その後動的に false に変更されることも念のため考慮して、 イベントハンドラの登録解除のコードも書いておきます。

 次に private static フィールドをいくつか定義しています。これはコメントに書いてあるとおりのための定義で、 後々登場するのでそちらで説明します。

 最後に登録するイベントハンドラが並んでいます。 これからこれらの中身をコーディングすることになります。

PreviewMouseLeftButtonDown イベント

 ここでは、マウス左ボタンが押されたことを確認し、掴むアイテムを捕捉します。

private static void control_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    _parent = sender as ItemsControl;
    var draggedItem = e.Source as FrameworkElement;
    if ((_parent == null) || (draggedItem == null))
        return;

    // ドラッグされるアイテムを取得する
    _draggedItem = _parent.ContainerFromElement(draggedItem);
    if (_draggedItem == null)
        return;

    // ドラッグされるアイテムのインデックスを取得する
    var itemsSourceList = _parent.ItemsSource as IList;
    itemsSourceList = itemsSourceList == null ? _parent.Items as IList : itemsSourceList;
    _draggedItemIndex = itemsSourceList.IndexOf(draggedItem);

    // ドラッグ開始位置を取得する
    var vw = Window.GetWindow(_parent);
    _originPoint = vw.PointToScreen(e.GetPosition(vw));

    _isMouseDown = true;
}
Code 2 : PreviewMouseLeftButtonDown イベントハンドラ

 マウス左ボタンが押されたときに発生するイベントなので、すべての始まりとなるイベントです。 始めにイベント発行元を ItemsControl 派生コントロールとして _parent で保持します。 また、クリックされた FrameworkElement がドラッグ対象となるアイテムとなるので、 _draggedItem でこれを保持します。

 ContainerFromElement() メソッドは、指定されたコントロールを所有するコンテナを返すメソッドです。 ItemsControl コントロールでは、コンテナ要素にユーザデータを詰め込んで、 そのコンテナを ItemsPresenter コントロールに並べることで UI 表現をおこなっています。 したがって、ドラッグされる本当の中身は e.Source ですが、 ItemsControl コントロールが扱うアイテムはコンテナ要素なので、_parent.ContainerFromElement(draggedItem) をドラッグ対象アイテムとして扱います。

 次にドラッグ対象となったアイテムがリストの何番目にあるアイテムなのかを調べて、 _draggedItemIndex で保持します。

 次に、ドラッグ開始位置をウィンドウからの相対位置として _originPoint で保持します。

 最後に、このビヘイビアによるシーケンスが開始されたことを _isMouseDown で保持します。

PreviewMouseMove イベント

 ここでは、アイテムを掴んだままマウスが動いたことを確認してドラッグ操作へ移行します。

private static void control_PreviewMouseMove(object sender, MouseEventArgs e)
{
    if (_isMouseDown && (e.LeftButton == MouseButtonState.Pressed))
    {
        // 現在位置を取得する
        var vw = Window.GetWindow(sender as DependencyObject);
        Point pt = vw.PointToScreen(e.GetPosition(vw));
        if (CheckDistance(_originPoint, pt))
        {
            DragDrop.DoDragDrop(sender as DependencyObject, _draggedItem, DragDropEffects.Move);

            // ドラッグ&ドロップが終わったらここに戻ってくるので
            // ここで状態をクリアする
            CleanUp();
        }
    }
}

#region ヘルパ
private static bool CheckDistance(Point x, Point y)
{
    return true;

    // こっちにすると
    // アイテム同士の境界線ギリギリからドラッグを開始すると
    // 別のアイテムに IsMouseOver が移ってしまって
    // ドラッグするアイテムと選択中アイテムが一致しなくなってしまう
    //return (Math.Abs(x.X - y.X) >= SystemParameters.MinimumHorizontalDragDistance)
    //    || (Math.Abs(x.Y - y.Y) >= SystemParameters.MinimumVerticalDragDistance);
}

/// <summary>
/// すべての状態をクリアする
/// </summary>
private static void CleanUp()
{
    _isMouseDown = false;
    _parent = null;
    _draggedItem = null;
}
#endregion ヘルパ
Code 3 : PreviewMouseMove イベントハンドラ

 PreviewMouseMove イベントはコントロールの上をマウスが通過するだけで発生するイベントです。 したがって、このビヘイビアによるシーケンス処理以外の場合は特に処理する必要はありません。 これを _isMouseDown というフラグで場合分けしています。 もし _isMouseDown が有効である場合、 PreviewMouseLeftButtonDown イベントハンドラによる処理がおこなわれているため、 マウスの左ボタンが押されている状態であればここでドラッグ操作を開始するかどうかを判定します。

 ドラッグ操作を開始するかどうかは、マウスを押した時点の位置と現在の位置との距離を測り、 その距離が長くなった場合にドラッグ操作を開始するようにしています。 CheckDistance() メソッドでは、システムが定めるドラッグ操作のための最小距離を用いて判定することもできますが、 ここでは常に true を返してすぐにドラッグ操作を開始するようにしています。 これは、コードのコメントにも書いてあるとおり、アイテム同士の境界線ギリギリでドラッグ操作をおこなうと、 アイテム選択中のハイライト色が隣のアイテムに移ってしまい、 ドラッグ対象となるアイテムと選択中のアイテムが一致しなくなってしまうからです。

 ドラッグ操作を開始するには DragDrop クラスの DoDragDrop() 静的メソッドを使用します。 このメソッドを呼ばれた時点から (おそらく非同期で) ドラッグ操作の処理に入ります。 そして、ドロップ操作がおこなわれるかあるいはドラッグ操作がキャンセルされると、 最終的にこのメソッドが呼ばれた位置に戻ってきます。 つまり、DoDragDrop() メソッドの続きに書くコードは、 ドラッグ&ドロップ操作が終了した後のコードになります。

 ドラッグ&ドロップ操作が終了した後は、 次の操作を待機するためにこのビヘイビアの状態を初期状態に戻すため、CleanUp() メソッドをコールしています。

PreviewDragOver イベント

 ここでは、コントロール上でドラッグ操作されているときの処理をおこないます。

private static void control_PreviewDragOver(object sender, DragEventArgs e)
{
    // ドラッグ操作中のマウス動作中は Adorner の位置を更新する
}
Code 4 : PreviewDragOver イベントハンドラ

 ドラッグ操作されているときは特に処理することはありません。 ただし、ドラッグ操作中にドラッグ対象アイテムのゴーストを表示したい場合は、 ここでそのための Adorner の表示位置を更新する必要があります。

 ここでは Adorner は蛇足になるので省略しています。

PreviewDragLeave イベント

 ここでは、ドラッグ操作中にコントロールの外にはみ出たときの処理をおこないます。

private static void control_PreviewDragLeave(object sender, DragEventArgs e)
{
    // ドラッグ操作中にコントロールからはみ出した場合は Adorner を隠す
}
Code 5 : PreviewDragLeave イベントハンドラ

 ここもドラッグ操作中の処理なので、特にすることはありません。

 Adorner によるゴースト表示を行っている場合は、ここでその Adorner が表示されないようにすることもあります。

PreviewDragEnter イベント

 ここでは、ドラッグ操作中に一度コントロールの外にはみ出て、 その後再びコントロールの内側へ入ったときの処理をおこないます。

private static void control_PreviewDragEnter(object sender, DragEventArgs e)
{
    // Adorner の表示を復帰する
}
Code 6 : PreviewDragEnter イベントハンドラ

 ここもドラッグ操作中の処理なので、特にすることはありません。

 Adorner によるゴースト表示を行っている場合は、ここでその Adorner が表示されるようにすることもあります。

PreviewDrop イベント

 ここでは、ドロップ操作されたときの処理をおこないます。

private static void control_PreviewDrop(object sender, DragEventArgs e)
{
    if (!_isMouseDown)
        return;

    var itemsControl = sender as ItemsControl;
    if (itemsControl == null)
        return;

    // ドロップする位置にあるアイテムのインデックスを取得する
    var itemsSourceList = itemsControl.ItemsSource as IList;
    itemsSourceList = itemsSourceList == null ? itemsControl.Items as IList : itemsSourceList;
    var dropTargetItem = e.Source as FrameworkElement;
    var dropTargetItemIndex = dropTargetItem != null ? itemsSourceList.IndexOf(dropTargetItem) : itemsSourceList.Count;
    dropTargetItemIndex = dropTargetItemIndex < 0 ? itemsSourceList.Count - 1 : dropTargetItemIndex;

    // ドラッグされたアイテムをドロップした位置に差し替える
    if (_parent == itemsControl)
    {
        itemsSourceList.Remove(_draggedItem);
        itemsSourceList.Insert(dropTargetItemIndex, _draggedItem);
    }
    else
    {
        var parentList = _parent.ItemsSource as IList;
        parentList = parentList == null ? _parent.Items as IList : parentList;
        parentList.Remove(_draggedItem);
        itemsSourceList.Insert(dropTargetItemIndex < 0 ? 0 : dropTargetItemIndex, _draggedItem);
    }

    e.Handled = true;
}
Code 7 : PreviewDrop イベントハンドラ

 いよいよドラッグ&ドロップ操作で本当にやりたいことをここでコーディングします。 ドラッグされたアイテムをドロップした位置に差し替えたいということで、 まずはドロップする位置にあるアイテムのインデックスを取得します。 これは PreviewMouseLeftButtonDown イベントハンドラでやった方法と同じやり方で取得できます。 というのも、PreviewDrop イベントハンドラに対するイベント引数は、 型名は DragEventArgs ですが、 Drop イベントなのでイベント発行元はドロップされたコントロールですし、 イベント引数もドロップされたコントロールに関する情報になっているからです。 つまり、ドロップ操作されたコントロールの情報はメソッドの引数から引き出せますが、 実際にドラッグされてきたアイテムの情報は別に保持しておかなければいけないということです。 この例では private static フィールドである _draggedItem で保持しているので問題ありません。

 そしてドラッグされたアイテムをドロップした位置に差し替えるために、 IList の Remove() メソッドで元のリストから一旦削除し、 Add() メソッドで挿入したいインデックスに入れ直します。

 このとき、ドロップ先が同じリストであれば PreviewDrop イベントハンドラの引数から取得したリストから Remove() すれば問題ありませんが、 違うリストにドロップした場合は、_parent で保持した元々の親要素のリストから Remove() しないといけません。 _parent のリストから Remove() しなかった場合、 Add() メソッドをコールしたときに、既に別の論理親がいるという例外が発生してしまいます。

 最後にドロップ操作はここで終了ということで e.Handled を true にして以降の処理をキャンセルします。

PreviewMouseUp イベント

 ここでは、ドラッグ対象となるアイテムが単純にクリック操作されたときの処理をおこないます。

private static void control_PreviewMouseUp(object sender, MouseButtonEventArgs e)
{
    // 単純なクリックで Drop 操作されないようにここで状態をクリアする
    CleanUp();
}
Code 8 : PreviewMouseUp イベントハンドラ

 DragDrop.DoDragDrop() メソッドをコールしてドラッグ&ドロップ操作に入ると、 ドロップ操作後は Mouseup イベントは発生しません。 したがって、特に何もする必要もないですが、 単純にクリックされると PreviewMouseLeftButtonDown イベントは発生して、一連の処理がおこなわれるため、 ここでそれらの状態を念のためクリアしておきます。

Designed by CSS.Design Sample