WPF の ListBox において、ドラッグアンドドロップ(以下、D&D)でアイテムを並べ替える方法を説明する。また、ドラッグ中はマウスカーソル変更だけでなく、半透明のドラッグオブジェクト(以下、ゴースト)を表示する。
さて、WPF はビジュアルのカスタマイズがかなりできるので、たかが ListBox でもけっこう凝ったことができる。そこで欲しくなるのが、D&D でアイテムを並び替えるギミックだ。
実はこれは簡単だ。だが、ひとたびゴーストを表示しようと思うと一筋縄ではいかない。ここでは苦悩の末、ListBox 中のアイテムをゴーストつきで並べ替えるコードを紹介する。
そもそもこの半透明のゴースト(本当の呼び名は知らない)の情報が極端に少ない。唯一参考になったのが、下記のオノッチさんのサイトだ。
このページでも基本的にこのオノッチさんのコードをベースとしている(というより、ほとんどそのまま)。 オノッチさんのおかげで半透明のオブジェクトは Adorner で描画すべきであることがわかった。
実装の前に、いくつかクラスを追加しておく必要がある。長くなるので、ページの最後に示す。
基本的な考え方としては、
という流れだ。
面倒なので、先にイベントハンドラをすべて定義しておく。番号ごとのイベントを下記に示す。
今回はすべて 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 dragItem;
Point dragStartPos;
DragAdorner dragGhost;
private void listBoxItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// マウスダウンされたアイテムを記憶
dragItem = sender as ListBoxItem;
// マウスダウン時の座標を取得
dragStartPos = e.GetPosition(dragItem);
}
実際にドラッグを行う部分がこのイベントハンドラである。
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;
}
}
}
ドラッグ中は常にこのイベントが発生するので、ゴーストの位置をマウスに追従させればよい。
ただ、オノッチさんのコードでは、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;
}
}
ここは基本的にリストボックスのアイテムを入れ替える処理だ。 入れ替えるためには
が必要である。
なお、今回の場合、リストボックスにはコレクションをバインドしているので、アイテムの入れ替えは 元のコレクションの操作で行っている。そのため、lbi.DataContext などを取得しているが、バインドしていない場合は必要ない。
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);
}
これはドラッグ中に装飾レイヤーに表示するゴーストのクラスである。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;
}
}
これはオノッチさんのものと同じ。内部的には 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);
}
}
Kenz Yamada(山田研二)。1984年生。大阪。ちょっとずつ好きなプログラム作ってます。
好きなものはカメラと旅行。ガジェットや身の回り、ちょっとこだわります。
詳しくは Web mixi で。