トップ > Tech > CSharp > WPF > ListBoxのアイテムを半透明ゴーストつきドラッグアンドドロップで並べ替える

ListBoxのアイテムを半透明ゴーストつきドラッグアンドドロップで並べ替える

はじめに

WPF の ListBox において、ドラッグアンドドロップ(以下、D&D)でアイテムを並べ替える方法を説明する。また、ドラッグ中はマウスカーソル変更だけでなく、半透明のドラッグオブジェクト(以下、ゴースト)を表示する。

概要

さて、WPF はビジュアルのカスタマイズがかなりできるので、たかが ListBox でもけっこう凝ったことができる。そこで欲しくなるのが、D&D でアイテムを並び替えるギミックだ。

実はこれは簡単だ。だが、ひとたびゴーストを表示しようと思うと一筋縄ではいかない。ここでは苦悩の末、ListBox 中のアイテムをゴーストつきで並べ替えるコードを紹介する。

そもそもこの半透明のゴースト(本当の呼び名は知らない)の情報が極端に少ない。唯一参考になったのが、下記のオノッチさんのサイトだ。

このページでも基本的にこのオノッチさんのコードをベースとしている(というより、ほとんどそのまま)。 オノッチさんのおかげで半透明のオブジェクトは Adorner で描画すべきであることがわかった。

実装の前に、いくつかクラスを追加しておく必要がある。長くなるので、ページの最後に示す。

実装

イベントハンドラの定義

基本的な考え方としては、

  1. アイテムでマウス左ボタンが押されたときにそのアイテムと座標を記憶
  2. アイテムで左ボタンが押されたまま、ある程度マウスが動かされればドラッグ処理を開始
  3. ドラッグ中はゴーストの座標をマウスポインタの位置に追従させる
  4. リストボックスでドロップされたときに座標を調べてアイテムを入れ替え

という流れだ。

面倒なので、先にイベントハンドラをすべて定義しておく。番号ごとのイベントを下記に示す。

  1. ListBoxItem.PreviewMouseLeftButtonDown
  2. ListBoxItem.PreviewMouseMove
  3. ListBoxItem.QueryContinueDrag
  4. ListBox.Drop

今回はすべて XAML で定義している。ListBoxItem へは ListBox.ItemContainerStyle で下記のように定義してやればよい。

<ListBox Name="listBox" Drop="listBox_Drop">
    <ListBox.ItemContainerStyle>
        <Style TargetType="ListBoxItem">
            <EventSetter Event="PreviewMouseLeftButtonDown" Handler="listBoxItem_PreviewMouseLeftButtonDown"/>
            <EventSetter Event="PreviewMouseMove" Handler="listBoxItem_PreviewMouseMove"/>
            <EventSetter Event="QueryContinueDrag" Handler="listBoxItem_QueryContinueDrag"/>
        </Style>
    </ListBox.ItemContainerStyle>
</ListBox>

アイテムでマウス左ボタンが押されたときの処理(ListBoxItem.PreviewMouseLeftButtonDown)

ここでは、マウスが押されたときの座標とアイテムを記憶しておく。 アイテムを記憶しておくのは後からマウスが動いたときにマウスが押されたアイテムと同じかどうかを調べるためである。

また、クラス変数として、このあとのイベントハンドラ内で共通して使う変数を定義しておく。

ListBoxItem dragItem;
Point dragStartPos;
DragAdorner dragGhost;

private void listBoxItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    // マウスダウンされたアイテムを記憶
    dragItem = sender as ListBoxItem;
    // マウスダウン時の座標を取得
    dragStartPos = e.GetPosition(dragItem);
}

マウスが動いたときの処理(ListBoxItem.PreviewMouseMove)

実際にドラッグを行う部分がこのイベントハンドラである。

  • 4〜8行目:左マウスボタンが押されていて、ゴーストが null、sender が前項のアイテムと同じ、かつ、 マウス移動量がシステム設定値以上になったら、ドラッグのフェーズに入る。
  • 10行目:リストボックスへのドロップを許可する。
  • 12行目:まず、AdornerLayer.GetAdornerLayer メソッド(スタティック)でリストボックスの装飾レイヤーを得る。
  • 13行目:オーナー、装飾オブジェクト、透明度、ドラッグ開始位置を渡して、ゴーストを初期化する。
  • 14行目:ゴーストを装飾レイヤーへ追加
  • 15行目:ドラッグドロップ処理を開始(ここで、ドロップされるまでブロックされる)
  • 16行目:ゴーストを装飾レイヤーから削除
  • 17〜18行目:ゴーストとアイテムを初期化(null に)
  • 20行目:リストボックスへのドロップを禁止する。
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
private void listBoxItem_PreviewMouseMove(object sender, MouseEventArgs e)
{
    var lbi = sender as ListBoxItem;
    if (e.LeftButton == MouseButtonState.Pressed && dragGhost == null && dragItem == lbi)
    {
        var nowPos = e.GetPosition(lbi);
        if (Math.Abs(nowPos.X - dragStartPos.X) > SystemParameters.MinimumHorizontalDragDistance ||
            Math.Abs(nowPos.Y - dragStartPos.Y) > SystemParameters.MinimumVerticalDragDistance)
        {
            listBox.AllowDrop = true;
 
            var layer = AdornerLayer.GetAdornerLayer(listBox);
            dragGhost = new DragAdorner(listBox, lbi, 0.5, dragStartPos);
            layer.Add(dragGhost);
            DragDrop.DoDragDrop(lbi, lbi, DragDropEffects.Move);
            layer.Remove(dragGhost);
            dragGhost = null;
            dargItem = null;
 
            listBox.AllowDrop = false;
        }
    }
}

ドラッグ中の処理(ListBoxItem.QueryContinueDrag)

ドラッグ中は常にこのイベントが発生するので、ゴーストの位置をマウスに追従させればよい。

ただ、オノッチさんのコードでは、CursorInfo.GetNowPosition で取得した座標だけでゴーストの位置に設定していたが、なぜか私の環境では、 CursorInfo.GetNowPosition(this) も CursorInfo.GetNowPosition(listBox) も CursorInfo.GetNowPosition(lbi) もすべてウィンドウ上の位置を返した。そのため、ウィンドウ上でのリストボックスの左上の座標を求め、その分を減算して設定している。

private void listBoxItem_QueryContinueDrag(object sender, QueryContinueDragEventArgs e)
{
    if (dragGhost != null)
    {
        var p = CursorInfo.GetNowPosition(this);
        var loc = this.PointFromScreen(listBox.PointToScreen(new Point(0, 0)));
        dragGhost.LeftOffset = p.X -loc.X;
        dragGhost.TopOffset = p.Y -loc.Y;
    }
}

ドロップされたあとの処理(ListBox.Drop)

ここは基本的にリストボックスのアイテムを入れ替える処理だ。 入れ替えるためには

  1. ドロップされた座標
  2. 入れ替える対象のアイテム(ドラッグ中のアイテム)
  3. 1 のインデックス
  4. 入れ替え先(挿入先)のインデックス

が必要である。

なお、今回の場合、リストボックスにはコレクションをバインドしているので、アイテムの入れ替えは 元のコレクションの操作で行っている。そのため、lbi.DataContext などを取得しているが、バインドしていない場合は必要ない。

  • 3行目:ドロップされた位置を取得しておく。
  • 4行目:e.Data.GetData でドロップされた ListBoxItem が取得できる。
  • 5〜6行目:ListBoxItem.DataContext でバインド要素が得られるので、これの元の位置を取得しておく。
  • 7行目:最初の要素からループを回し、ドロップ座標から、アイテムの新しい位置(インデックス)を割り出す。見つかればその時点で要素を入れ替えて抜ける。見つからなければ、一番最後に要素をもってきて終わり。
  • 9行目:ListBox.Item はバインドされたオブジェクトなので、それぞれの ListBoxItem を取得したければ、listBox.ItemContainerGenerator を使う。
  • 10行目:アイテムの左端上下中央の listBox 上での座標を割り出している。なぜかというと、ここをアイテムの前に挿入するのか、後ろに挿入するのかの判断ポイントにしているからである。ListBoxItem の上半分でドロップされた場合はそのアイテムの前に、下半分の場合はそのアイテムの後ろに追加するようにする。なお、今回は縦にアイテムが積まれているリストボックスの場合なので Y 座標のみ見ているが、そうでない場合は、X 座標も評価する必要がある)
  • 15行目:ここではバインド元(MyClasses)は OvservableCollection<T> クラスであり、Move メソッドが用意されていたので、入れ替えはこれを利用した。三項演算子による分岐は要素が下方向に移動されたか上方向に移動されたによるものである。下方向に移動されている場合は、自分の分のインデックスを -1 する必要がある。
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
private void listBox_Drop(object sender, DragEventArgs e)
{
    var dropPos = e.GetPosition(listBox);
    var lbi = e.Data.GetData(typeof(ListBoxItem)) as ListBoxItem;
    var o = lbi.DataContext as MyClass;
    var index = MyClasses.IndexOf(o);
    for (int i = 0; i < MyClasses.Count; i++)
    {
        var item = listBox.ItemContainerGenerator.ContainerFromIndex(i) as ListBoxItem;
        var pos = listBox.PointFromScreen(item.PointToScreen(new Point(0, item.ActualHeight / 2)));
        if (dropPos.Y < pos.Y)
        {
            // i が入れ換え先のインデックス
            MyClasses.Move(index, (index < i) ? i - 1 : i);
            return;
        }
    }
    // 最後にもっていく
    int last = MyClasses.Count - 1;
    MyClasses.Move(index, last);
}

必要なクラス

DragAdorner クラス

これはドラッグ中に装飾レイヤーに表示するゴーストのクラスである。Adorner クラスから継承される。

オノッチさんのものでも十分だったが、コンストラクタの引数にアイテムのドラッグ開始位置(dragPos)を追加し、マウスが押された位置でオブジェクトをつかんでいるように見せるようにした。コンストラクタ内の XCenter, YCenter の値をドラッグ位置に書き換えているだけである。

class DragAdorner : Adorner
{
    protected UIElement _child;
    protected double XCenter;
    protected double YCenter;

    public DragAdorner(UIElement owner) : base(owner) { }

    public DragAdorner(UIElement owner, UIElement adornElement, double opacity, Point dragPos)
        : base(owner)
    {
        var _brush = new VisualBrush(adornElement){Opacity = opacity};
        var b = VisualTreeHelper.GetDescendantBounds(adornElement);
        var r = new Rectangle() { Width = b.Width, Height = b.Height};

        XCenter = dragPos.X;// r.Width / 2;
        YCenter = dragPos.Y;// r.Height / 2;

        r.Fill = _brush;
        _child = r;
    }


    private double _leftOffset;
    public double LeftOffset
    {
        get { return _leftOffset; }
        set
        {
            _leftOffset = value - XCenter;
            UpdatePosition();
        }
    }

    private double _topOffset;
    public double TopOffset
    {
        get { return _topOffset; }
        set
        {
            _topOffset = value - YCenter;
            UpdatePosition();
        }
    }

    private void UpdatePosition()
    {
        var adorner = this.Parent as AdornerLayer;
        if (adorner != null)
        {
            adorner.Update(this.AdornedElement);
        }
    }

    protected override Visual GetVisualChild(int index)
    {
        return _child;
    }

    protected override int VisualChildrenCount
    {
        get { return 1; }
    }

    protected override Size MeasureOverride(Size finalSize)
    {
        _child.Measure(finalSize);
        return _child.DesiredSize;
    }
    protected override Size ArrangeOverride(Size finalSize)
    {

        _child.Arrange(new Rect(_child.DesiredSize));
        return finalSize;
    }

    public override GeneralTransform GetDesiredTransform(GeneralTransform transform)
    {
        var result = new GeneralTransformGroup();
        result.Children.Add(base.GetDesiredTransform(transform));
        result.Children.Add(new TranslateTransform(_leftOffset, _topOffset));
        return result;
    }
}

CursorInfo クラス

これはオノッチさんのものと同じ。内部的には Win32API の GetCursorPos と ScreenToClient を呼んで、現在のマウス座標をコントロールのクライアント座標で返す GetNowPosition メソッドが用意されている。

なぜこのクラスが必要かというと、QueryContinueDrag イベントハンドラ中でマウスカーソルの位置を取得するためである。QueryContinueDrag のイベント引数 QueryContinueDragEventArgs には、マウスポインタ位置を返すメソッドはない。また、Mouse.GetPosition メソッドでも取得できそうだが、これはうまくいかない。

MSDN にも下記のように書かれているので、とりあえず Win32API を利用しておくほかないようだ。

ドラッグ アンド ドロップの操作中は、マウスの位置を GetPosition で確実に取得することはできません。これはドロップ操作が完了するまで、ドラッグ元の要素によってマウスの制御 (通常はキャプチャも含む) が行われ、基盤となる Win32 の呼び出しによって大部分の動作が制御されるためです。代わりに、次の方法を試してください。

  • ドラッグ イベント (DragEnter、DragOver、DragLeave) に渡す DragEventArgs の GetPosition メソッドを呼び出します。
  • P/Invoke を使用して、GetCursorPos を呼び出します。
public static class CursorInfo
{
    [DllImport("user32.dll")]
    private static extern void GetCursorPos(out POINT pt);

    [DllImport("user32.dll")]
    private static extern int ScreenToClient(IntPtr hwnd, ref POINT pt);

    private struct POINT
    {
        public UInt32 X;
        public UInt32 Y;
    }

    public static Point GetNowPosition(Visual v)
    {
        POINT p;
        GetCursorPos(out p);

        var source = HwndSource.FromVisual(v) as HwndSource;
        var hwnd = source.Handle;

        ScreenToClient(hwnd, ref p);
        return new Point(p.X, p.Y);
    }
}
(2010/09/26 20:09:25)
36014
プロフィール

Kenz Yamada(山田研二)。1984年生。大阪。ちょっとずつ好きなプログラム作ってます。 好きなものはカメラと旅行。ガジェットや身の回り、ちょっとこだわります。 詳しくは Web mixi で。

Bookmark and Share