[WPF] ListBoxでのドラッグ&ドロップ その1
2016/08/18
ListBox上のアイテムを、ドラッグ&ドロップで移動できる機能を実装していきたいと思います。
長くなるので、何回かに分けて紹介していきます。
今回は最低限のドラッグ&ドロップを実現するところまで。
 
処理の流れ
ListBoxでのドラッグ&ドロップ処理で必要となるイベント及びイベント内処理は以下。
各ステップでの処理詳細は後述します。
- マウス左ボタン押下(PreviewMouseLeftButtonDown)
- ドラッグアイテムの位置取得
 - ドラッグアイテムのデータ取得
 
 - マウス移動(PreviewMouseMove)
- ドラッグ開始判定
 - ドラッグ&ドロップ開始
 
 - マウス左ボタン押上(PreviewMouseLeftButtonUp)
- ドラッグ&ドロップ情報クリア
 
 - ドラッグ状態でコンテナ領域に入った(PreviewDragEnter)
- ドラッグアイテムの処理対象判定
 
 - ドラッグ状態でコンテナ領域から出た(PreviewDragLeave)
 - ドラッグ状態で移動(PreviewDragOver)
- ドラッグアイテムの処理対象判定
 
 - ドロップ(PreviewDrop)
- ドロップ
 
 
準備
まずはxaml側でこれらのイベントを登録しておきます。
また、ドロップ操作を許可するためにはAllowDropプロパティをTrueにしておく必要があるので、合わせて設定しておきます。
    ...
    <ListBox
        ...
        AllowDrop="True"
        PreviewMouseLeftButtonDown="listBox_PreviewMouseLeftButtonDown"
        PreviewMouseMove="listBox_PreviewMouseMove"
        PreviewMouseLeftButtonUp="listBox_PreviewMouseLeftButtonUp"
        PreviewDrop="listBox_PreviewDrop"
        PreviewDragEnter="listBox_PreviewDragEnter"
        PreviewDragLeave="listBox_PreviewDragLeave"
        PreviewDragOver="listBox_PreviewDragOver"/>
    ...
1. マウス左ボタン押下(MouseLeftButtonDown)
マウス左ボタン押下をドラッグ操作の起点とします。
以降の処理で使うため、ドラッグアイテムのデータとアイテムインデックス、そしてドラッグ開始位置を覚えておきます。
ドラッグアイテムはe.OriginalSourceプロパティに格納されているので、これを元に必要な情報を取得していきます。
まずアイテムデータですが、コンテナのDataContextプロパティがドラッグアイテムのデータそのものになっています。
コンテナはItemsControl.ContainerFromElementメソッドで取得します。
これらの処理をメソッド化しておきます。
    /// <summary>
    /// 指定アイテムを所有するコンテナを取得する。
    /// </summary>
    private FrameworkElement GetItemContainer(ItemsControl itemsControl, DependencyObject item)
    {
        //==== パラメータ確認 ====//
        if ((itemsControl == null) || (item == null))
        {
            return null;
        }
        //==== コンテナを取得 ====//
        return itemsControl.ContainerFromElement(item) as FrameworkElement;
    }
    /// <summary>
    /// 指定アイテムのデータを取得する。
    /// </summary>
    private object GetItemData(ItemsControl itemsControl, DependencyObject item)
    {
        var container = GetItemContainer(itemsControl, item);
        return (container == null) ? null : container.DataContext;
    }
これで、GetItemDataメソッドの引数にe.OriginalSourceプロパティを渡すことで、対応データを取得できるようになりました。
こうして取得したアイテムデータ。詳細は後述しますが、ドロップ対象判定のためにDataObjectオブジェクトに詰めて管理しておきます。
ここで、DataObjectコンストラクタの第1引数にデータ形式文字列(自身のデータを表すユニークな文字列)を設定しておきます。
    var dragItem = e.OriginalSource as FrameworkElement;
    var itemData = GetItemData(itemsControl, dragItem);
    dragData = new DataObject("ユニークな文字列", itemData);
 
次にアイテムインデックスを取得する方法を考えます。
ItemsControl.ItemsSourceがアイテムコレクションであるため、IList.IndexOfメソッドの引数に先程取得したアイテムデータを渡せばインデックスが取得できます。
    /// <summary>
    /// 指定アイテムデータのItemsControl内でのインデックスを取得する。
    /// </summary>
    private int GetItemIndex(ItemsControl itemsControl, object data)
    {
        var items = itemsControl.ItemsSource as IList;
        int idx = items.IndexOf(data);
        return (idx != -1) ? idx : (int?)null;
    }
    private void AnyFunc(...)
    {
        ...
        dragItemIdx = GetItemIndex(itemsControl, itemData);
        ...
    }
 
ドラッグ開始位置はe.GetPositionメソッドで取得できます。
    // マウス押下位置を取得
    startPos = e.GetPosition(this);
以上をまとめたコードが以下。
    /// <summary>
    /// ドラッグデータ形式を表す文字列
    /// <summary>
    private const string DRAG_DATA_FMT = "ItemsControlDragAndDropData";
    /// <summary>
    /// 開始位置
    /// <summary>
    private Point? startPos = null;
    /// <summary>
    /// ドラッグアイテムのインデックス
    /// <summary>
    private int? dragItemIdx = null;
    /// <summary>
    /// ドラッグデータ
    /// <summary>
    private DataObject dragData = null;
    /// <summary>
    /// マウス左ボタン押下ハンドラ
    /// <summary>
    private void listBox_PreviewMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        //==== 送信者判定 ====//
        ItemsControl itemsControl = sender as ItemsControl;
        if (itemsControl != null)
        {
            //==== ドラッグアイテムを取得 ====//
            var dragItem = e.OriginalSource as FrameworkElement;
            if (dragItem != null)
            {
                //-==- ドラッグアイテムあり -==-//
                //==== ドラッグアイテムのデータ取得 ====//
                var itemData = GetItemData(itemsControl, dragItem);
                if (itemData != null)
                {
                    //==== ドラッグデータを設定 ====//
                    dragData = new DataObject(DRAG_DATA_FMT, itemData);
                    dragItemIdx = GetItemIndex(itemsControl, itemData);
                    //==== 開始位置を設定 ====//
                    var pos = e.GetPosition(itemsControl);
                    startPos = itemsControl.PointToScreen(pos);
                }
            }
        }
    }
2. マウス移動(PreviewMouseMove)
マウス押下時に即座にドラッグを開始しても構いませんが、クリック時に意図しないドラッグ&ドロップ操作が実行されてしまわないように、開始点から一定距離の移動があった場合にのみドラッグを開始するものとします。
距離については自由に定義してよいのですが、今回は以下のシステム定義値を使用します。
    SystemParameters.MinimumHorizontalDragDistance
    SystemParameters.MinimumVerticalDragDistance
X/Y座標のいずれかで、開始点と現在点の差分絶対値が上記定義値より大きければドラッグ開始可能とします。
メソッド化するとこんな感じ。
    /// <summary>
    /// ドラッグ開始とする距離を移動したかどうかの判定を行う。
    /// </summary>
    private bool IsDragStartable(Vector delta)
    {
        return (SystemParameters.MinimumHorizontalDragDistance < Math.Abs(delta.X)) ||
               (SystemParameters.MinimumVerticalDragDistance < Math.Abs(delta.Y));
    }
ドラッグ開始可能であればドラッグを開始します(ん?何やら日本語が変ですね…^^;)。
具体的にはDragDrop.DoDragDropメソッドをコールするだけでオッケーです。
第1引数にドラッグ元オブジェクト、第2引数にドラッグアイテムデータ、第3引数でドラッグ&ドロップで許容する操作を指定します。
第3引数にはリンクやコピー等も指定できるのですが、今回は移動のみ可能とします。
このメソッドをコールすることにより、マウスイベントからドラッグ&ドロップ関連イベントへのスイッチが行われます。
(マウスイベントがマスクされ、ドラッグ&ドロップ関連イベントが発行されるようになります。)
この状態はドラッグ&ドロップの一連の動作が完了するまで継続されます。
※スイッチとは書きましたが、実際は同期メソッドとなっており、ドラッグがキャンセルされるかドロップ操作が完了するまで処理が返ってきません。
ドラッグ&ドロップ処理から返ってきたタイミングで、次回操作のために関連情報をクリーンアップしておきましょう。
    /// <summary>
    /// ドラッグ&ドロップ関連データをクリーンアップする。
    /// </summary>
    private void CleanUpDragDropAndDropData()
    {
        startPos = null;
        dragItemIdx = null;
        dragData = null;
    }
    
    
    /// <summary>
    /// PreviewMouseMoveハンドラ
    /// </summary>
    private void listBox_PreviewMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
    {
        //==== 送信者判定 ====//
        ItemsControl itemsControl = sender as ItemsControl;
        if (itemsControl != null)
        {
            if (startPos != null)
            {
                //==== ドラッグ開始可否判定 ====//
                Point curPos = itemsControl.PointToScreen(e.GetPosition(itemsControl));
                Vector diff = curPos - (Point)startPos;
                if (IsDragStartable(diff))
                {
                    //-==- ドラッグ開始可能 -==-//
                    
                    //==== ドラッグ&ドロップ開始 ====//
                    DragDrop.DoDragDrop(itemsControl, dragData, DragDropEffects.Move);
                    
                    
                    //==== クリーンアップ ====//
                    CleanUpDragDropAndDropData();
                }
            }
        }
    }
3. マウス左ボタン押上(PreviewMouseLeftButtonUp)
マウス押下後、ドラッグ開始距離分の移動が無い状態でマウス押上された場合にこのイベントがコールされます。
次のドラッグ処理に備えて、押下時に取得したドラッグ&ドロップ関連情報をクリーンアップしておきます。
    /// <summary>
    /// PreviewMouseLeftButtonUpハンドラ
    /// </summary>
    private void listBox_PreviewMouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        //==== ドラッグ&ドロップ関連データをクリーンアップ ====//
        CleanUpDragDropAndDropData();
    }
4. ドラッグ状態でコンテナ領域に入った(PreviewDragEnter)
PreviewDragEnterイベントは、ドラッグオブジェクトがドロップ先の境界の中にドラッグされるときに発生します。
ここではドラッグアイテムが処理対象かどうかの判定を行います。
ドラッグアイテムが処理対象であればe.Effectsプロパティに「DragDrop.DoDragDropメソッドの第3引数で指定したものと同じ値」を、対象外であれば「DragDropEffects.None」を設定します。
処理対象かどうかの判断基準は以下。
- ドラッグアイテムデータは対応形式?
 - ドラッグ元が自分自身?
 
前者については、ドラッグアイテムが、自身が処理できる形式のものかどうかであり、DragEventArgs.Data.GetDataPresentメソッドを用いることでチェックできます。
引数にDataObject生成時に指定したデータ形式文字列(自身のデータを表すユニークな文字列)を渡して、戻り値がtrueになっていれば対応形式だということになります。
(データ形式が分かっているので処理可能と判断する。)
後者については、自身のListBox内での移動であることを確認するためのもので、senderとDragEventArgs.Sourceを比較します。
同一であれば、自身のListBox内での移動であることを表します。
ここら辺については仕様による部分なので、必須の実装ではありません。
もし、別アプリや別コントロールからのドロップに対応するような場合は不要です。
e.Effectsプロパティへの設定をしたら、最後に、処理したことを知らせるためにe.Handledプロパティをtrueにします。
    /// <summary>
    /// PreviewDragEnterハンドラ
    /// </summary>
    private void listBox_PreviewDragEnter(object sender, DragEventArgs e)
    {
        //==== データの使用可否判定 ====//
        // 非対応データは受け入れ拒否する。
        //
        bool isDragData = e.Data.GetDataPresent(DRAG_DATA_FMT);
        if (isDragData && (sender == e.Source))
        {
            //==== 操作設定:移動可 ====//
            e.Effects = DragDropEffects.Move;
        }
        else
        {
            //==== 受け入れ拒否 ====//
            e.Effects = DragDropEffects.None;
        }
        e.Handled = true;
    }
5. ドラッグ状態でコンテナ領域から出た(PreviewDragLeave)
オブジェクトがドロップ先の境界の外にドラッグされるときに発生します。
最低限のドラッグ&ドロップ機能を実現するだけであれば、ここで処理することは何も無いのですが、今後の機能拡張のために入れ物だけ用意しておきます。
    /// <summary>
    /// PreviewDragLeaveハンドラ
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void listBox_PreviewDragLeave(object sender, DragEventArgs e)
    {
    }
6. ドラッグ状態で移動(PreviewDragOver)
オブジェクトがドロップ先の境界内でドラッグ(移動)する間、継続的に発生します。
ここでもPreviewDragEnter同様にドラッグアイテムが処理対象かどうかの判定を行います。
こちらだけに実装しておいても問題なさそうなのですが、PreviewDragEnter時にも同様の処理を入れていないと、ドロップ禁止カーソル(?)がちらつくことがあるので、両方に実装してあります。
処理の内容はPreviewDragEnter時と全く同じ。
    /// <summary>
    /// PreviewDragOverハンドラ
    /// </summary>
    private void listBox_PreviewDragOver(object sender, DragEventArgs e)
    {
        //==== データの使用可否判定 ====//
        // 非対応データは受け入れ拒否する。
        //
        bool isDragData = e.Data.GetDataPresent(DRAG_DATA_FMT);
        if (isDragData && (sender == e.Source))
        {
            //==== 操作設定:移動可 ====//
            e.Effects = DragDropEffects.Move;
        }
        else
        {
            //==== 受け入れ拒否 ====//
            e.Effects = DragDropEffects.None;
        }
        e.Handled = true;
    }
7. ドロップ(PreviewDrop)
ドロップされたときにコールされます。
ドロップ処理としてやるべきことは2つ。
・ListBoxからドラッグ位置のアイテムを削除
・ListBoxのドロップ位置にドラッグアイテムを追加/挿入
こうすることでアイテムが入れ替わったように見えます。
どちらもItemsControl.ItemsSourceプロパティに対しての処理であり、削除はRemoveメソッド、追加/挿入はAdd/Insertメソッドを用います。
ドロップ位置の取得はドラッグ時と同様。
ハンドラ第2引数のOriginalSourceプロパティにドロップアイテムが格納されているので、これを元にドロップ先のアイテムインデックスを取得します。
ドロップ位置がドラッグアイテム位置より後ろにある場合は、ドラッグアイテムが削除されることを考慮してドロップアイテム位置を1つ小さな値にします。
    dropItemIdx -= (dragItemIdx < dropItemIdx) ? 1 : 0;
ドロップ位置にアイテムが無い場合はGetItemIndexメソッドの戻り値がnullになります。
この場合は、末尾にドラッグアイテムを追加するようにしておきます。
ドロップ処理が終わったら関連情報をクリーンアップ。
    /// <summary>
    /// Dropハンドラ
    /// </summary>
    private void listBox_Drop(object sender, DragEventArgs e)
    {
        var itemsControl = sender as ItemsControl;
        if (itemsControl != null)
        {
            //==== ドラッグ位置のアイテムを削除 ====//
            var items = itemsControl.ItemsSource as IList;
            var data = dragData.GetData(DRAG_DATA_FMT);
            items.Remove(data);
            //==== ドロップ位置にアイテムを挿入 ====//
            var dropObj = e.OriginalSource as DependencyObject;
            var dropData = GetItemData(itemsControl, dropObj);
            int? dropItemIdx = GetItemIndex(itemsControl, dropData);
            if (dropItemIdx != null)
            {
                //-==- ドロップ位置にアイテムがある -==-//
                items.Insert((int)dropItemIdx, data);
            }
            else
            {
                //-==- ドロップ位置にアイテムが無い -==-//
                items.Add(data);
            }
            DropItem(itemsControl, dropItemIdx);
            //==== クリーンアップ ====//
            CleanUpDragDropAndDropData();
        }
    }
まとめ
長くなりましたが、以上が最低限のドラッグ&ドロップ機能の実装となります。