読者です 読者をやめる 読者になる 読者になる

亀岡的プログラマ日記

京都のベッドタウン、亀岡よりだらだらとお送りいたします。

WPFでListBoxアイテムのドラッグ&ドロップを実装する。 #adcjcs

これはC#AdventCalander7日目の記事です。これまでかなりコードよりな話が多かったので多分に浮いている空気がしますが、気にせずに時期を外したWPFTipsを書いてみます。UIたのしいよUI!


と、いうわけで。

概要

WPFはListoxを始めとしたItemsControl系が非常に強力です。そして物が順番に並んでいると、人は並び替えたくなるものです。そんな時にUp/Downボタンで上下させるとか、やってられませんよね??

f:id:posaunehm:20121207082905p:plain


そんな訳で、最近あまり触れてなかったWPFで、ItemsControl要素のドラッグ&ドロップ実装をリハビリ代わりに書いているので、それの概要などをお伝えしようかなと思います。

ItemsControlの構造

ItemsControlは内部的にはItemsPanel → ItemContainer → Item という三重の構造になっています。

イメージとしては、ItemContainerはItemsPanelの中に隙間なくびっしり配置されており(勿論ItemContainerのStyleによってマージンなどは付けられますが)、その中にItemがホストされている、というイメージでいいかと思います。

Itemsプロパティを設定している場合にはItemの部分に外から挿入されたコントロールが入ります。ItemsSourceを設定している場合には、ItemsSourceの個々の要素にItemsTemplateで指定されたDataTemplateが当てられ作成されたコントロールがItemに入っています。

ドラッグ&ドロップの仕組み

それではWPFでのイベントの流れもおさらいしながら、どんな感じで作るかおさらいしておきましょう。

まずは実装としては大元はこんな感じ。

f:id:posaunehm:20121207084111p:plain

とりあえずの実装としてはItemsPanelをWrapPanelに置き換えたListBoxを使っています。また、FluidBehaviorも入っているのでややヌルヌル動きますw。大まかなXAMLコードはこんな感じに。

<Grid>
    <ListBox 
        Margin="0,0,0,18" 
        ItemsPanel="{DynamicResource ItemsWrapPanelTemplate}"
        ScrollViewer.HorizontalScrollBarVisibility="Disabled" 
        PreviewMouseLeftButtonDown="ListBox_PreviewMouseLeftButtonDown"
        PreviewMouseMove="ListBox_PreviewMouseMove" 
        PreviewMouseUp="ListBox_PreviewMouseUp" 
        PreviewDrop="ListBox_PreviewDrop"
        AllowDrop="True" 
        PreviewDragEnter="ListBox_PreviewDragEnter" 
        PreviewDragLeave="ListBox_PreviewDragLeave"
        PreviewDragOver="ListBox_PreviewDragOver" 
        ItemsSource="{Binding ListBoxItemSamples}"
        ItemTemplate="{DynamicResource ListBoxItemTemplate}" />
</Grid>

んでもってリソースはこんな感じ。

<Window.Resources>
    <ItemsPanelTemplate x:Key="ItemsWrapPanelTemplate">
        <WrapPanel IsItemsHost="True">
            <i:Interaction.Behaviors>
                <ei:FluidMoveBehavior AppliesTo="Children" Duration="0:0:0.5" />
            </i:Interaction.Behaviors>
        </WrapPanel>
    </ItemsPanelTemplate>
    <DataTemplate x:Key="ListBoxItemTemplate">
        <Grid d:DesignWidth="196.677" d:DesignHeight="183.259" Width="100" Height="120">
            <Image Source="{Binding ImageSource, Mode=OneWay}"/>
            <TextBlock HorizontalAlignment="Center" 
                        VerticalAlignment="Bottom"
                        Text="{Binding Description}" />
        </Grid>
    </DataTemplate>
</Window.Resources>

イベントの流れ

では、イベントの流れをおさらいしましょう。使うイベントはMouseのDown・Move・Upと、DragのEnter・Over・Leaveです。では時系列順に見て行きましょう。
なお、コード内は別定義したメソッドや拡張メソッドなどを多用していますので、細かい処理はもっと多くはなります。細かいところはソースコードを参照してくださいませ。

MouseDown

まずMouseDown時に開始座標をキャプチャしておきます。それから動かすターゲットに関する情報を記憶しておきます。

private void ListBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    var itemsControl = sender as ItemsControl;
    var draggedItem = e.OriginalSource as FrameworkElement;

    if (itemsControl == null || draggedItem == null){ return;}

    _draggedData = itemsControl.GetItemData(draggedItem);
    if(_draggedData == null){return;}
            
    _initialPosition = this.PointToScreen(e.GetPosition(this));
    _mouseOffsetFromItem = itemsControl.PointToItem( draggedItem, _initialPosition.Value);
    _draggedItemIndex = itemsControl.GetItemIndex(_draggedData);
}

動かすターゲットのデータそのものに、後々ゴーストイメージを出すときの補助用にオフセット座標が必要になってきます。マウス開始位置はドラッグ開始の判定用ですね。

MouseMove

それからMouseMoveを監視します。念のため言っておくと、このイベントはMouseが動いている間シグナル的に出ているものですね。
これでDragに十分な距離を動いたのかどうかを判定します。そしてD&Dできると判定されたら、ドラッグ状態用のゴーストイメージ(_dragContentAdorner)を作成しDoDragDropでD&Dを開始します。

private void ListBox_PreviewMouseMove(object sender, MouseEventArgs e)
{
    var itemsControl = sender as ItemsControl;

    if (_draggedData == null || _initialPosition == null || itemsControl== null)
    {
        return;
    }

    var currentPos = this.PointToScreen(e.GetPosition(this));
    if (!MovedEnoughForDrag((_initialPosition - currentPos).Value))
    {
        return;
    }

    _dragContentAdorner = new DragContentAdorner(
        itemsControl, _draggedData, itemsControl.ItemTemplate, _mouseOffsetFromItem);
    _dragContentAdorner.SetScreenPosition(currentPos);

    DragDrop.DoDragDrop(itemsControl, _draggedData, DragDropEffects.Move);
    CleanUpData();
}

この時点でMouse系のイベント経路は一旦なくなり、Drag系のイベント発火へと移ります。

DragEnter

新しいオブジェクトにドラッグ中のアイテムがオーバーされた場合、DragEnterが発火します。まぁMouseEnterのようなものと思っていただければ。DragEnterでは挿入場所を表すオブジェクト(線が出るだけですが)の表示をCreateInsertionAdoenerで行なっています。

private void ListBox_PreviewDragEnter(object sender, DragEventArgs e)
{
    var itemsControl = sender as ItemsControl;
    if (itemsControl == null) { return; }

    CreateInsertionAdorner(e.OriginalSource as DependencyObject, itemsControl);
}
DragOver

DragOverはMouseMoveと似たようなものと思ってください。ここではゴーストイメージの座標を更新し、ドラッグ中のマウス位置に合わせて表示するようにしています。

private void ListBox_PreviewDragOver(object sender, DragEventArgs e)
{
    var currentPos = this.PointToScreen(e.GetPosition(this));
    _dragContentAdorner.SetScreenPosition(currentPos);
}
DragLeave

DragLeaveはDragEnterの逆でMouseLeaveの用にあるオブジェクトからオーバー状態が解除された時に発火します。挿入位置表示用のAdornerを回収しています。

private void ListBox_PreviewDragLeave(object sender, DragEventArgs e)
{
    if (_insertionAdorner != null)
    {
        CreanUpInsertionAdorner();
    }
}
Drop

これがD&D系最後のイベントになります。マウスがアップされその場所にアイテムがドロップされた時に発生しますね。
ここでは登録されるアイテムを取得し、そのアイテムがあった位置にドラッグしているアイテムを挿入します。

private void ListBox_PreviewDrop(object sender, DragEventArgs e)
{
    var itemsControl = sender as ItemsControl;
    if (itemsControl == null) { return; }

    var dropTargetData = itemsControl.GetItemData(e.OriginalSource as DependencyObject);
    DropItemAt(itemsControl.GetItemIndex(dropTargetData), itemsControl);
}
MouseUp

これはエラー系です。D&Dに必要な距離よりも以前にマウスアップが行われたり、その他イベントフローを外れるような場合のために、マウスアップ時にデータをクリアするようにしています。

private void ListBox_PreviewDrop(object sender, DragEventArgs e)
{
    var itemsControl = sender as ItemsControl;
    if (itemsControl == null) { return; }

    var dropTargetData = itemsControl.GetItemData(e.OriginalSource as DependencyObject);
    DropItemAt(itemsControl.GetItemIndex(dropTargetData), itemsControl);
}

・・・とだいたいこんな感じでD&Dイベントを扱うと、そこそこに見栄えがする動きを作ることができました。こんな感じになっております

その他ポイント

Adornerとうまく付き合う

実装していく中で、ドラッグ中のイメージや、挿入カーソルを表示するために、実は今回初めてAdorner系をまともに使ってみたりしました。

AdornerはUI要素の修飾用に使われる部品で、要素の最上位レイヤーにAdornerLayerというものを作り、そこに描画を行います。コントロールの中身を直接買えるのではなく付加的にでんザインを加えたり情報を付加したりするときによく使いますね。

身近な例だとWPF用SpyツールであるSnoopが、ターゲット要素に赤四角を出すときにAdornerを使っているようですね。

Adorner単体だと、絵を書くのにコードをガリガリ弄る必要があったり、登録/解除の処理が面倒くさそうと割りとアレだったので、Base用のクラスを作成したりしてます。

まだまだやりたいこと

とりあえず記事としてはこれでお終いですが、まだまだ実装したりないところは沢山あります。

例えばこれは今コントロールにガリガリ書いていますが、一般化するためにはBehavior化することが望ましいです。実質(割と意識して)ローカルのコントロールを使わないよう作っているので、割と容易かな、と思います。

もう一つは、やはりこの見通しの悪いコードをどうにかしたい!ということ。一時保存用のフィールド変数が多すぎて狂いそうです。まぁここはRxなんだろーなー、と思いつつなかなか取り組めず・・・

そんなこんなで、以下でボチボチやっているので暇があったらツッコミなりコメントなりpull requestなりしてくださいませ!