WPFでのドラッグ&ドロップの最初の一歩

現在作っているアプリケーションはSilverlightでUIを作っています。
アプリ上で用意したコントロールを自由に移動・リサイズ・プロパティの編集をできるようにしているわけですが、それを作っているのは自分ではなくチームのもう一人のメンバーです。
先日、だいたい動くものができたとの事でコミットされたソースを見たのですが・・・うまく理解できない;;
というか、ここまで動的なUI開発をした事が正直無いので(つまりマウスイベントをたくさん取ったりするやつです)、完全にハマッてしまいました。
ちなみに作ったメンバーはゲーム畑出身なのです、さすがです。
そんな訳で、自分でも少しは付いていけるようにするためにまずはドラッグ&ドロップから勉強していきます。

Thumbコントロールで行う簡単なドラッグ&ドロップ

WPFSilverlight両方にThumb(読み:サム)というコントロールが用意されてるみたいです。
ListBoxをExpressionBlendで分解してる時に出てきたのを覚えてますが、「ドラッグ可能なコントロール」って事だったんですね。確かにスクロールバーのつまみにつかわれてました。
ドラッグ可能というだけあって以下のようなイベントが最初から用意されてます。以下MSDNから引用

DragStarted Thumb コントロールが論理フォーカスおよびマウス キャプチャを受け取ると発生します。
DragDelta Thumb コントロールに論理フォーカスおよびマウス キャプチャがあるときにマウスの位置が変更されると、1 回以上発生します。
DragCompleted Thumb コントロールでマウスのキャプチャがなくなると発生します。
Thumbでのドラッグ&ドロップのサンプル

手っ取り早くCanvas上の絶対配置でのサンプルとして作ります。
XAML

<Window x:Class="TumbSample.TumbSampleWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="TumbSampleWindow" Height="300" Width="300">
    <Canvas x:Name="LayoutRoot">
        <Thumb x:Name="thumb1" 
               Canvas.Left="80" Canvas.Top="80" 
               Background="Green" Width="20" Height="20" 
               DragDelta="onDragDelta" 
               DragStarted="onDragStarted" 
               DragCompleted="onDragCompleted"
               />
    </Canvas>
</Window>

コードビハインド

using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;

namespace TumbSample {
    /// <summary>
    /// TumbSampleWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class TumbSampleWindow : Window {
        public TumbSampleWindow() {
            InitializeComponent();
        }

        void onDragDelta(object sender, DragDeltaEventArgs e) {
            // 位置の変化分を加算して再設定
            Canvas.SetLeft(thumb1, Canvas.GetLeft(thumb1) + e.HorizontalChange);
            Canvas.SetTop(thumb1, Canvas.GetTop(thumb1) + e.VerticalChange);
        }

        void onDragStarted(object sender, DragStartedEventArgs e) {
            // カーソルを手形に変更
            Cursor = Cursors.Hand;
        }

        void onDragCompleted(object sender, DragCompletedEventArgs e) {
            // カーソルを矢印に戻す
            Cursor = Cursors.Arrow;
        }
    }
}

びっくりするぐらい簡単ですね!
ただ四角形がマウスで移動できて、移動時にはカーソルが手形になるだけですけれど、思っていたよりずっと簡単でした。

通常のコントロールCanvas上で行う簡単なドラッグ&ドロップ

次にThumbを使わずに通常のコントロールでドラッグ&ドロップを行ってみます。
Thumbとの大きな違いは、
移動させるコントロール自身が自身の移動量を知っているのではなく、
移動するパネル(今回はCanvas)でマウスの移動値からコントロールの移動量を算出するという部分みたいです。(もしかしたらいい方法があるのかもしれませんが。。)

使用するイベント

今回の簡単なサンプルであれば以下で十分みたいです。以下MSDNから引用。

PreviewMouseLeftButtonDown マウス ポインタがこの要素上にあるときにマウスの左ボタンが押されると発生します。
PreviewMouseMove マウス ポインタがこの要素上にあるときに移動すると発生します。
PreviewMouseLeftButtonUp マウス ポインタがこの要素上にあるときにマウスの左ボタンが離されると発生します。

Preview〜」でなくても今回の例では大丈夫でした。
ちなみにPreviewが付くとトンネルイベント、Previewが無いとバブルイベントとして動作するみたいです。
左マウスのイベントは下のコントロールには通知しないものとして、トンネルイベントで実装しそこで止めてしまう事にしています。

通常コントロールでのドラッグ&ドロップのサンプル

XAML

<Window x:Class="SimpleDragDropSample.SimpleDragDropSample"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="SimpleDragDropSample" Height="300" Width="300">
    <Canvas x:Name="LayoutRoot"
            PreviewMouseLeftButtonDown="canvas_PreviewMouseLeftButtonDown"
            PreviewMouseMove="canvas_PreviewMouseMove"
            PreviewMouseLeftButtonUp="canvas_PreviewMouseLeftButtonUp"
            Background="AliceBlue">
        <Ellipse
                 Fill="Green" Width="50" Height="50" 
                 Canvas.Left="80" Canvas.Top="80"/>
        <Ellipse
                 Fill="Yellow" Width="50" Height="50" 
                 Canvas.Left="150" Canvas.Top="80"/>
    </Canvas>
</Window>

コードビハインド側

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace SimpleDragDropSample {
    /// <summary>
    /// SimpleDragDropSample.xaml の相互作用ロジック
    /// </summary>
    public partial class SimpleDragDropSample : Window {
        /// <summary>
        /// ドラッグ中かどうか
        /// </summary>
        private bool isDragging;
        /// <summary>
        /// 現在のマウス位置
        /// </summary>
        /// <remarks>ドラッグ中のみ更新</remarks>
        private Point currentMousePoint;
        /// <summary>
        /// ドラッグ中のUI要素
        /// </summary>
        private UIElement dragginUIElement;

        public SimpleDragDropSample() {
            InitializeComponent();
        }

        private void canvas_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
            if (LayoutRoot.Equals(e.Source)) {
                return;
            }

            this.isDragging = true;
            this.dragginUIElement = e.Source as UIElement;
            this.currentMousePoint = e.GetPosition(null);
            // カーソルを手形に変更
            Cursor = Cursors.Hand;

            // これ以上トンネルイベントが通知されないように
            e.Handled = true;
        }

        private void canvas_PreviewMouseMove(object sender, MouseEventArgs e) {
            if (!this.isDragging) {
                return;
            }

            // 位置の変化分を算出
            Point newMousePoint = e.GetPosition(null);
            double horizontalChange = newMousePoint.X - this.currentMousePoint.X;
            double verticalChange = newMousePoint.Y - this.currentMousePoint.Y;

            // 位置の変化分を加算して再設定
            Canvas.SetLeft(this.dragginUIElement, Canvas.GetLeft(this.dragginUIElement) + horizontalChange);
            Canvas.SetTop(this.dragginUIElement, Canvas.GetTop(this.dragginUIElement) + verticalChange);

            // 現在位置更新
            this.currentMousePoint = newMousePoint;
        }

        private void canvas_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) {
            this.isDragging = false;
            this.dragginUIElement = null;
            // カーソルを矢印に戻す
            Cursor = Cursors.Arrow;

            // これ以上トンネルイベントが通知されないように
            e.Handled = true;
        }
    }
}

イベントの感知元が移動コントロールを持つCanvasになるため今回は最低限として以下の判定を入れています

  • マウス左クリック押下時のコントロールイベント発生元がCanvas自身ではないか
  • マウス移動時にドラッグ中であるか

Canvasは背景や透過度等を何も設定しない状態だとマウスクリックイベントを発生させないので注意してください。詳しくはココ

作ってみれば、Thumbの時とそれほど違わない感じで作成できました。
しかも、このサンプルではWindow外にマウスカーソルが移動した時とか、色々問題はありそうです。

よりドラッグ&ドロップっぽくしてみる

ここまでサンプルを作ってはみましたが、どちらも「コントロールを移動しているだけ」って事に気づきました。なんかドラッグ&ドロップっていうと、半透明っぽい分身が現れて、そいつが移動したりしますよね。ですよね!

修飾のためのAdornerクラス

最初は移動させるUIElementをコピーさせて〜とか考えてましたが、調べてみるとAdornerというクラスを使うサンプルが多いようです。せっかくなので使ってみましょう。
AdornerはUIElementにバインドされ、バインド先を装飾するという事のようです。でも、単純にバインド先に張り付くというよりはバインド元の装飾レイヤに乗っかるだけって事みたいです。だから配置する場所も自由、それをドラッグ中のコントロールに見せかけるって魂胆みたいですね。
以下MSDNのAdornerからの引用です。

装飾は、UIElement にバインドされるカスタム FrameworkElement です。装飾は、装飾対象の要素、または装飾対象の要素のコレクションの上に常に存在するレンダリング サーフェイスである装飾層で描画されます。装飾の描画は、装飾のバインド先の UIElement の描画からは独立しています。通常、装飾は、装飾対象の要素の左上に位置する標準の 2 次元座標の原点を使用して、バインド先の要素に対して相対的な位置に配置されます。

さて、Adornerは抽象クラスなので継承クラスを作る必要があるわけですが、いまいちどうすればいいのか掴めません。まず簡単な表示オンリーサンプルを作ってみます。

Adornerの簡単なサンプル

Ellipseを装飾するAdornerクラス

public class EllipseAdorner : Adorner {

    private Ellipse adornerChild;

    public EllipseAdorner(UIElement adornedElement)
        : base(adornedElement) {
        // 装飾用の小さなEllipseを作る
        adornerChild = new Ellipse();
        adornerChild.Width = 30;
        adornerChild.Height = 30;
        adornerChild.Fill = Brushes.Aqua;
    }

    protected override Size MeasureOverride(Size constraint) {
        adornerChild.Measure(constraint);
        return adornerChild.DesiredSize;
    }

    protected override Size ArrangeOverride(Size finalSize) {
        adornerChild.Arrange(new Rect(finalSize));
        return finalSize;
    }

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

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

XAML

<Window x:Class="AdornerSample.AdornerSample"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="AdornerSample" Height="300" Width="300">
    <Canvas x:Name="LayoutRoot"
            Background="AliceBlue">
        <Ellipse x:Name="ellipse1"
                 Fill="Green" Width="50" Height="50" 
                 Canvas.Left="80" Canvas.Top="80"/>
    </Canvas>
</Window>

コードビハインド側

using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Shapes;

namespace AdornerSample {
    /// <summary>
    /// AdornerSample.xaml の相互作用ロジック
    /// </summary>
    public partial class AdornerSample : Window {
        public AdornerSample() {
            InitializeComponent();

            this.Loaded += new RoutedEventHandler(AdornerSample_Loaded);
        }

        void AdornerSample_Loaded(object sender, RoutedEventArgs e) {
            // 装飾クラスを作り、装飾対象の装飾レイヤに追加
            EllipseAdorner adorner = new EllipseAdorner(this.ellipse1);
            AdornerLayer layer = AdornerLayer.GetAdornerLayer(this.ellipse1);
            layer.Add(adorner);
        }
    }
}

実行結果

なるほど、装飾対象の左上に配置されてますね。
では、次に場所を移動させてみます。今回は以下の2つの方法で試してみます。

  • Adorner#ArrangeOverride時の装飾要素のArrange設定に位置情報も加えてみる
  • Adorner#GetDesiredTransformをオーバーライドし変換要素に平行移動変換(TranslateTransform)を追加する。

ではやってみます。

ArrangeOverride時に設定する場合
public class EllipseAdorner : Adorner {

    private Ellipse adornerChild;

    public EllipseAdorner(UIElement adornedElement)
        : base(adornedElement) {
        // 装飾用の小さなEllipseを作る
        adornerChild = new Ellipse();
        adornerChild.Width = 30;
        adornerChild.Height = 30;
        adornerChild.Fill = Brushes.Aqua;
    }

    protected override Size MeasureOverride(Size constraint) {
        adornerChild.Measure(constraint);
        return adornerChild.DesiredSize;
    }

    protected override Size ArrangeOverride(Size finalSize) {
        // 左上隅からの位置情報を加えてArrangeに設定する
        Point location = new Point(10, 10);
        Rect rect = new Rect(location, finalSize);
        adornerChild.Arrange(rect);
        return finalSize;
    }

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

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

実行結果

10pxづつずらす事で中央に配置されました。

GetDesiredTransform時に設定する場合
public class EllipseAdorner : Adorner {

    private Ellipse adornerChild;

    public EllipseAdorner(UIElement adornedElement)
        : base(adornedElement) {
        // 装飾用の小さなEllipseを作る
        adornerChild = new Ellipse();
        adornerChild.Width = 30;
        adornerChild.Height = 30;
        adornerChild.Fill = Brushes.Aqua;
    }

    protected override Size MeasureOverride(Size constraint) {
        adornerChild.Measure(constraint);
        return adornerChild.DesiredSize;
    }

    protected override Size ArrangeOverride(Size finalSize) {
        adornerChild.Arrange(new Rect(finalSize));
        return finalSize;
    }

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

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

    public override GeneralTransform GetDesiredTransform(GeneralTransform transform) {
        // 変換を複数にするためグループにして、左上から10pxづつ平行移動させる変換を追加
        GeneralTransformGroup transformGroup = new GeneralTransformGroup();
        transformGroup.Children.Add(base.GetDesiredTransform(transform));
        transformGroup.Children.Add(new TranslateTransform(10, 10));
        return transformGroup;
    }
}

実行結果はさっきのと同じです。
せっかくなので変換の種類をまとめておきます。

回転 RotateTransform
スケーリング ScaleTransform
傾斜 SkewTransform
平行移動 TranslateTransform

装飾要素(adornerChild)を複数にする時は、UIElementのリストとかで管理するとoverrideメソッドの実装が簡単になります。

GetDesiredTransformでドラッグに追従する装飾を作る

さて、どちらの方法でもドラッグ時に装飾要素を移動させる事はできそうですが、イベントの発火回数を見ているとArrangeOverrideはGetDesiredTransformを一回多く発火されているみたい。(すいません、理由はわかりません。)
加えて、複数の要素を修飾要素とした場合にはArrangeで要素一つずつ設定するよりも、変換としてまとめて移動とかができる方が今のところ楽そうなのでGetDesiredTransformの方が使いやすそうです。

ここからはMSDNにあったサンプルを元にコードを改造してみます。

まずは装飾クラスです。ポイントは

  • 装飾要素は装飾元のサイズ・塗りつぶし色を引継ぎ、移動中を表すように半透明にする。
  • 装飾要素の移動は装飾元の左上からの相対座標となるため、OffSetとして表現する。
  • OffSetが更新されるたびに装飾レイヤを更新し、変換を適用させる。
public class EllipseAdorner : Adorner {

    private Ellipse adornerChild = null;
    private double leftOffset = 0;
    private double topOffset = 0;

    public EllipseAdorner(UIElement adornedElement)
        : base(adornedElement) {
        VisualBrush _brush = new VisualBrush(adornedElement);

        adornerChild = new Ellipse();
        adornerChild.Width = adornedElement.RenderSize.Width;
        adornerChild.Height = adornedElement.RenderSize.Height;
        adornerChild.Fill = _brush;
        adornerChild.Opacity = 0.5;
    }

    protected override Size MeasureOverride(Size constraint) {
        adornerChild.Measure(constraint);
        return adornerChild.DesiredSize;
    }

    protected override Size ArrangeOverride(Size finalSize) {
        adornerChild.Arrange(new Rect(finalSize));
        return finalSize;
    }

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

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

    public double LeftOffset {
        get { return leftOffset; }
        set {
            leftOffset = value;
            UpdatePosition();
        }
    }

    public double TopOffset {
        get { return topOffset; }
        set {
            topOffset = value;
            UpdatePosition();
        }
    }

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

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

次にこれを使うソースですが、XAML通常コントロールでのドラッグ&ドロップのサンプルと同じです。コードビハインドのみ修正します。

using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;

namespace AdornerDragDropSample {
    /// <summary>
    /// AdornerDragDropSample.xaml の相互作用ロジック
    /// </summary>
    public partial class AdornerDragDropSample : Window {
        /// <summary>
        /// マウスが押下されているかどうか
        /// </summary>
        private bool isMouseDown;
        /// <summary>
        /// ドラッグ中かどうか
        /// </summary>
        private bool isDragging;
        /// <summary>
        /// ドラッグ開始時のエレメントの左上位置
        /// </summary>
        private Point startDragElementPoint;
        /// <summary>
        /// ドラッグ開始時のマウスの位置
        /// </summary>
        private Point dragStartMousePoint;
        /// <summary>
        /// ドラッグ元のUI要素
        /// </summary>
        private UIElement dragsourceUIElement;
        /// <summary>
        /// ドラッグ中のオーバーレイ要素
        /// </summary>
        private EllipseAdorner overlayElement;

        public AdornerDragDropSample() {
            InitializeComponent();
        }

        private void canvas_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
            if (LayoutRoot.Equals(e.Source)) {
                return;
            }

            this.isMouseDown = true;
            this.dragsourceUIElement = e.Source as UIElement;
            this.dragStartMousePoint = e.GetPosition(null);
            // カーソルを手形に変更
            Cursor = Cursors.Hand;
            // これ以上トンネルイベントが通知されないように
            e.Handled = true;
        }

        private void canvas_PreviewMouseMove(object sender, MouseEventArgs e) {
            if (!this.isMouseDown) {
                return;
            }

            if (!this.isDragging) {
                DragStarted();
            } else {
                DragMoved();
            }
        }

        private void canvas_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) {

            DragFinished();
            // カーソルを矢印に戻す
            Cursor = Cursors.Arrow;
            // これ以上トンネルイベントが通知されないように
            e.Handled = true;
        }

        private void DragStarted() {
            isDragging = true;
            // 最終的な移動値を得るために開始時の要素の位置を保存する
            startDragElementPoint.X = Canvas.GetLeft(dragsourceUIElement);
            startDragElementPoint.Y = Canvas.GetTop(dragsourceUIElement);

            overlayElement = new EllipseAdorner(dragsourceUIElement);
            AdornerLayer layer = AdornerLayer.GetAdornerLayer(dragsourceUIElement);
            layer.Add(overlayElement);
        }

        private void DragMoved() {
            Point currentMousePosition = System.Windows.Input.Mouse.GetPosition(this.LayoutRoot);

            // 現在とドラッグ開始時のマウス位置からオフセット値を算出し更新
            overlayElement.LeftOffset = currentMousePosition.X - dragStartMousePoint.X;
            overlayElement.TopOffset = currentMousePosition.Y - dragStartMousePoint.Y;
        }

        private void DragFinished() {
            if (isDragging) {
                AdornerLayer.GetAdornerLayer(overlayElement.AdornedElement).Remove(overlayElement);

                // ドラッグ開始時の要素の位置にオーバーレイ要素のオフセットを加算して、オーバーレイ要素の位置に移動させる
                Canvas.SetLeft(dragsourceUIElement, startDragElementPoint.X + overlayElement.LeftOffset);
                Canvas.SetTop(dragsourceUIElement, startDragElementPoint.Y + overlayElement.TopOffset);

                overlayElement = null;
            }
            isDragging = false;
            isMouseDown = false;
        }
    }
}

ほぼサンプルを拝借した感じになってしまいましたが、ドラッグ開始時にドラッグ対象要素の左上位置を保存するのが注意ですね。代わりに左上位置とドラッグ開始時のマウスカーソルの差分を保存しておいても同じですが、要素の左上位置を持っておくほうがわかりやすいですね。

カーソルが見えないのでわかりにくいですが実行画面です。半透明になってるのがドラッグ途中のものです。

まとめ

最初の一歩として調べ調べやってましたが、やはり大変ですね。。
まだパネル間のドラッグ&ドロップはできてないので次回はその辺を調べていかないと。