【WPF】ItemsControlをドラッグ&ドロップで並び替え（異なるコントロール間・TreeView対応版）

## 概要

[こちらの記事](http://yujiro15.net/blog/index.php?id=151 "YKSoftware - ListBox などのアイテムをドラッグ＆ドロップ操作で並べ替える")をベースに、下記の機能を追加したバージョンのBehaviorです。

- 異なるコントロール間での操作
- TreeViewへの対応

個人的な好みで変数名を変えたり新しい記法に対応させたところ以外は基本的に参考元のままですが、何点かポイントとなる変更点があるのでそこについてのみ解説していきます。その他の箇所は元記事の解説が非常に丁寧なのでそちらを参照。

## コード

<details><summary>ベースクラス</summary>

```C#:BehaviorBase.cs
public abstract class BehaviorBase<T1, T2> : Behavior<T2> where T2 : DependencyObject
{
 public static DependencyProperty TargetProperty = DependencyProperty.Register(
                nameof(Target),
                typeof(T1),
                typeof(BehaviorBase<T1, T2>),
                new FrameworkPropertyMetadata(default(T1), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, null));

 public T1 Target
 {
  get => (T1)GetValue(TargetProperty);
  set => SetValue(TargetProperty, value);
 }

 protected override void OnAttached()
 {
  base.OnAttached();
  OnAttachedAction();
 }

 protected override void OnDetaching()
 {
  OnDetachingAction();
  base.OnDetaching();
 }

 protected abstract void OnAttachedAction();
 protected abstract void OnDetachingAction();
}
```

</details>

こちらはMicrosoft.Xaml.Behaviors.Behavior\<T>[^1]の単なるラッパークラスなので、この部分は次に載せるBehavior本体に直接書いてしまっても問題ありません。やっていることは基本的なDependencyPropertyの定義と、アタッチ/デタッチ時のbase.On～()を省略できるようにabstractメソッドを用意しているだけです。ちなみに今回に関しては、FrameworkPropertyMetadataのBindsTwoWayByDefaultは不要です[^2]。

<details><summary>Behavior</summary>

```C#:DraggableItemsControlBehavior.cs
public class DraggableItemsControlBehavior : BehaviorBase<Action<ItemMovement>, ItemsControl>
{
    protected static DraggedObject? draggedObject;

    protected override void OnAttachedAction()
    {
        AssociatedObject.PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown;
        AssociatedObject.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp;
        AssociatedObject.PreviewMouseMove += OnPreviewMouseMove;
        AssociatedObject.PreviewDragEnter += OnPreviewDragEnter;
        AssociatedObject.PreviewDragLeave += OnPreviewDragLeave;
        AssociatedObject.PreviewDrop += OnPreviewDrop;
    }

    protected override void OnDetachingAction()
    {
        AssociatedObject.PreviewMouseLeftButtonDown -= OnPreviewMouseLeftButtonDown;
        AssociatedObject.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp;
        AssociatedObject.PreviewMouseMove -= OnPreviewMouseMove;
        AssociatedObject.PreviewDragEnter -= OnPreviewDragEnter;
        AssociatedObject.PreviewDragLeave -= OnPreviewDragLeave;
        AssociatedObject.PreviewDrop -= OnPreviewDrop;
    }


    #region EventHandler
    private void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (sender is not FrameworkElement element || e.OriginalSource is not FrameworkElement source) return;
        var pos = GetPosition(e, element);
        var item = ViewHelper.FindTemplatedRoot(source);
        draggedObject = new DraggedObject(pos, item);
    }

    private void OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        draggedObject = null;
    }

    private void OnPreviewMouseMove(object sender, MouseEventArgs e)
    {
        if (sender is not FrameworkElement element || !(draggedObject?.IsDraggable(GetPosition(e, element)) ?? false)) return;
        DragDrop.DoDragDrop(element, draggedObject.DroppedItem, DragDropEffects.Move);
        draggedObject = null;
    }

    private void OnPreviewDragEnter(object sender, DragEventArgs e)
    {
        if (draggedObject is null) return;
        draggedObject.IsDroppable = true;
    }

    private void OnPreviewDragLeave(object sender, DragEventArgs e)
    {
        if (draggedObject is null) return;
        draggedObject.IsDroppable = false;
    }

    private void OnPreviewDrop(object sender, DragEventArgs e)
    {
        if (e.OriginalSource is not FrameworkElement element || draggedObject is null) return;

        var targetContainer = ViewHelper.FindTemplatedRoot(element);
        var sourceCollection = ViewHelper.FindVisualParentItemsControl(draggedObject.DroppedItem)?.ItemsSource as IList;
        var targetItemsControl = ViewHelper.FindVisualParentItemsControl(targetContainer);
        var targetCollection = targetItemsControl?.ItemsSource as IList;
        if (sourceCollection is null || targetCollection is null || targetItemsControl is null) return;
        if (!sourceCollection.GetType().IsGenericType || !targetCollection.GetType().IsGenericType) return;

        var sourceItemTypes = sourceCollection.GetType().GenericTypeArguments;
        var targetItemTypes = targetCollection.GetType().GenericTypeArguments;
        if (sourceItemTypes.Length != 1 || targetItemTypes.Length != 1) return;

        var droppedItem = draggedObject.DroppedItem.DataContext;
        if (droppedItem is null) return;

        if (sourceItemTypes[0] == targetItemTypes[0])
        {
            var index = targetItemsControl.ItemContainerGenerator.IndexFromContainer(targetContainer);
            index = index >= 0 ? index : targetCollection.Count;
            Target?.Invoke(new ItemMovement(sourceCollection, targetCollection, droppedItem, index));
            return;
        }

        var children = (targetContainer as ItemsControl)?.ItemsSource as IList;
        if (children is null) return;

        var childType = children.GetType();
        if (!childType.IsGenericType) return;

        var childTypeArgments = childType.GenericTypeArguments;
        if (childTypeArgments.Count() != 1 || childTypeArgments[0] != sourceItemTypes[0]) return;

        Target?.Invoke(new ItemMovement(sourceCollection, children, droppedItem, children.Count));
    }
    #endregion

    private static Point GetPosition(MouseEventArgs e, FrameworkElement element) => e.GetPosition(Window.GetWindow(element));


    protected class DraggedObject
    {
        public Point Start { get; }
        public FrameworkElement DroppedItem { get; }
        public bool IsDroppable { get; set; }

        private static readonly Vector minDragPoint = new Vector(SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance);

        public DraggedObject(Point start, FrameworkElement droppedItem)
        {
            Start = start;
            DroppedItem = ViewHelper.FindTemplatedRoot(droppedItem);
        }

        public bool IsDraggable(Point current)
        {
            return (current - Start).Length >= minDragPoint.Length;
        }
    }
}
```

```C#:ItemMovement.cs
public class ItemMovement
{
    public IList Source { get; }
    public IList Target { get; }
    public object MovedItem { get; }
    public int TargetIndex { get; }

    public ItemMovement(IList source, IList target, object movedItem, int targetIndex)
    {
        Source = source;
        Target = target;
        MovedItem = movedItem;
        TargetIndex = targetIndex;
    }

    public void MoveItem()
    {
        Source.Remove(MovedItem);
        Target.Insert(TargetIndex > Target.Count ? Target.Count : TargetIndex, MovedItem);
    }
}
```

</details>

実際に使用するBehaviorクラスです。一部を除いてほぼ元記事そのままで、変更点としては

- Callbackの引数をint → ItemMovementに変更
  - ItemMovementクラスには並び替え対象等のプロパティと並び替えメソッドが定義されており、基本的にはMoveItem()を呼び出すだけで並び替えが完了します。Callback先で何かしらの制御をしたいときはプロパティを使用します。
- OnPreviewDropの中身（詳細は後述）
- DraggedObjectの初期化に必要な情報をコンストラクタで渡すように変更（後で書き換えたりしないのでIsDroppable以外のプロパティはget-onlyにできます）。

<details><summary>ヘルパークラス</summary>

```C#:ViewHelper.cs
public static class ViewHelper
{
    public static ItemsControl? FindVisualParentItemsControl(DependencyObject? obj)
    {
        var self = obj;
        while (obj is not null)
        {
            obj = VisualTreeHelper.GetParent(obj);
            if (obj is ItemsControl parent) return parent;
        }
        return self as ItemsControl;
    }

    public static FrameworkElement FindTemplatedRoot(FrameworkElement element)
    {
        if (element.TemplatedParent is not FrameworkElement parent) return element;
        while (parent!.TemplatedParent is FrameworkElement pElement) parent = pElement;
        return parent;
    }
}
```

</details>

親をたどって必要なFrameworkElementを取得するためのメソッド群です。FindTemplatedRootは特に変更点ありませんが、FindVisualParentItemsControlというメソッドを追加しています。名前の通りVisulaParentの中でItemsControlを見つけるメソッドで、最後まで該当する親が見つからなかった場合、引数自身をItemsControlにキャストして返すようになっています。引数がItemsControlでなかった場合はキャストの結果としてnullが返ります。
こいつで見つけたItemsControlのItemsSourceが、Callbackの引数として渡されます。同一コントロール内の並び替えのみであればもっと単純なやり方[^3]でできるのですが、異なるコントロール間での移動にも対応するにはこの細工が必要になります[^4]。

## OnPreviewDrop

前節で省略したOnPreviewDropの詳細です（コード再掲）。

```C#:OnPreviewDrop
private void OnPreviewDrop(object sender, DragEventArgs e)
{
    //TreeViewとそれ以外に共通の処理
    if (e.OriginalSource is not FrameworkElement element || draggedObject is null) return;

    var targetContainer = ViewHelper.FindTemplatedRoot(element);
    var sourceCollection = ViewHelper.FindVisualParentItemsControl(draggedObject.DroppedItem)?.ItemsSource as IList;
    var targetItemsControl = ViewHelper.FindVisualParentItemsControl(targetContainer);
    var targetCollection = targetItemsControl?.ItemsSource as IList;
    if (sourceCollection is null || targetCollection is null || targetItemsControl is null) return;
    if (!sourceCollection.GetType().IsGenericType || !targetCollection.GetType().IsGenericType) return;

    var sourceItemTypes = sourceCollection.GetType().GenericTypeArguments;
    var targetItemTypes = targetCollection.GetType().GenericTypeArguments;
    if (sourceItemTypes.Length != 1 || targetItemTypes.Length != 1) return;

    var droppedItem = draggedObject.DroppedItem.DataContext;
    if (droppedItem is null) return;

    //アイテムの型が一致していればここでCallback呼び出し
    if (sourceItemTypes[0] == targetItemTypes[0])
    {
        var index = targetItemsControl.ItemContainerGenerator.IndexFromContainer(targetContainer);
        index = index >= 0 ? index : targetCollection.Count;
        Target?.Invoke(new ItemMovement(sourceCollection, targetCollection, droppedItem, index));
        return;
    }

    //TreeView専用の処理
    var children = (targetContainer as ItemsControl)?.ItemsSource as IList;
    if (children is null) return;   //TreeView意外でアイテムの型が不一致の場合はここで弾かれる

    var childType = children.GetType();
    if (!childType.IsGenericType) return;

    var childTypeArgments = childType.GenericTypeArguments;
    if (childTypeArgments.Count() != 1 || childTypeArgments[0] != sourceItemTypes[0]) return;

    Target?.Invoke(new ItemMovement(sourceCollection, children, droppedItem, children.Count));
}
```

元/先のアイテムの型が同じときにCallbakを呼び出す処理はTreeViewでもそれ以外でも共通なので、その処理まず行います。
先ほどのFindVisualParentItemsControlを使って元/先のItemsControlを取得し、そのItemsSourceをIListにキャストします。その後両コレクションがGenericであるかを調べ、そうでなければそこで処理を終了しています。具体的な型情報が不要になるように非Generic版であるIListとして扱っていますが、実際の中身としてはGenericであってほしいので（この型の一致/不一致でこの後の処理が分岐します）。
ドラッグ対象オブジェクトについてはdraggedObject.TargetItemがコンテナなのでそのDataContextを単純に取得すればよく、ドラッグ先indexについては取得方法自体は元記事と同様ですが、値が0未満だった場合は末尾に追加されるように再代入を行っています。

TreeView専用処理の対象となるのは、子ノードを親と同階層のノードにドロップした時です。その場合はドロップ先ノードの子として追加されてほしいので、targetContainerのItemsSourceを取得して型を調べ、条件を満たしていればそのItemsSourceをドロップ先コレクションとしてCallbackを呼び出します。

## 使ってみる

<details><summary>テスト用コード</summary>

```C#:Models.cs
public class Parent
{
    public string Name { get; }
    public ReactiveCollection<Child> Children { get; } = new();

    public Parent(string name) => Name = name;
}

public class Child
{
    public string Name { get; }
    public ReactiveCollection<GrandChild> GrandChildren { get; } = new();

    public Child(string name) => Name = name;
}

public class GrandChild
{
    public string Name { get; }

    public GrandChild(string name) => Name = name;
}
```

```C#:MainWindowViewModel
public class MainWindowViewModel
{
    public ReactiveCollection<Parent> Parents1 { get; } = new();
    public ReactiveCollection<Parent> Parents2 { get; } = new();
    public ReactiveCollection<Parent> Parents3 { get; } = new();
    public ReactiveCollection<Child> Children { get; } = new();
    public Action<ItemMovement> DroppedAction => MoveItem;

    public MainWindowViewModel()
    {
        //適当にアイテムを追加
        var parent1a = new Parent("Parent1-A");
        var child1 = new Child("Child1-A");
        child1.GrandChildren.Add(new GrandChild("GrandChild1-A"));
        parent1a.Children.Add(child1);
        parent1a.Children.Add(new Child("Child1-B"));
        var parent1b = new Parent("Parent1-B");
        parent1b.Children.Add(new Child("Child1-C"));
        parent1b.Children.Add(new Child("Child1-D"));
        Parents1.Add(parent1a);
        Parents1.Add(parent1b);
        Parents2.Add(new Parent("Parent2-A"));
        Parents2.Add(new Parent("Parent2-B"));
        Parents3.Add(new Parent("Parent3-A"));
        Parents3.Add(new Parent("Parent3-B"));
        Children.Add(new Child("Child2-A"));
        Children.Add(new Child("Child2-B"));
    }

    private void MoveItem(ItemMovement itemMovement)
    {
        itemMovement.MoveItem();
    }
}
```

```xml:MainWindow.xaml（一部）
<StackPanel Orientation="Horizontal">
    <TreeView
        ItemsSource="{Binding Parents1}"
        MinWidth="100"
        MinHeight="20"
        AllowDrop="true">
        <i:Interaction.Behaviors>
            <myBehaviors:DraggableItemsControlBehavior Target="{Binding DroppedAction, Mode=OneWay}" />
        </i:Interaction.Behaviors>
        <TreeView.Resources>
            <HierarchicalDataTemplate
                ItemsSource="{Binding Children}"
                DataType="{x:Type localModel:Parent}">
                <TextBlock Text="{Binding Name}" />
            </HierarchicalDataTemplate>
            <HierarchicalDataTemplate
                ItemsSource="{Binding GrandChildren}"
                DataType="{x:Type localModel:Child}">
                <TextBlock Text="{Binding Name}" />
            </HierarchicalDataTemplate>
            <DataTemplate DataType="{x:Type localModel:GrandChild}">
                <TextBlock
                    Text="{Binding Name}"
                    Margin="3,2" />
            </DataTemplate>
        </TreeView.Resources>
    </TreeView>
    <ListBox
        ItemsSource="{Binding Parents2}"
        MinWidth="100"
        MinHeight="20"
        AllowDrop="true">
        <i:Interaction.Behaviors>
            <myBehaviors:DraggableItemsControlBehavior Target="{Binding DroppedAction, Mode=OneWay}" />
        </i:Interaction.Behaviors>
        <ListBox.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Name}" />
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
    <ListBox
        ItemsSource="{Binding Parents3}"
        MinWidth="100"
        MinHeight="20"
        AllowDrop="true">
        <i:Interaction.Behaviors>
            <myBehaviors:DraggableItemsControlBehavior Target="{Binding DroppedAction, Mode=OneWay}" />
        </i:Interaction.Behaviors>
        <ListBox.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Name}" />
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
    <ListBox
        ItemsSource="{Binding Children}"
        MinWidth="100"
        MinHeight="20"
        AllowDrop="true">
        <i:Interaction.Behaviors>
            <myBehaviors:DraggableItemsControlBehavior Target="{Binding DroppedAction, Mode=OneWay}" />
        </i:Interaction.Behaviors>
        <ListBox.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Name}" />
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
</StackPanel>
```

</details>

適当に階層構造を持つモデルを作り、適当にアイテムを追加して画面に表示します。3階層のTreeViewと、同じ型のアイテムのListBox2つに型の異なるListBox一つを配置しています。
同一コントロール内での並び替えに加えて、異なるコントロール間での移動、型の異なるコントロールへの移動不可、TreeViewでの親階層へのドロップ時の挙動が実現できているのが分かると思います。

![DraggableItemsControlBehaviorTest.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595852/ff072038-82c2-d187-e436-0b711e21eafe.gif)

ちなみにコントロールへMinWidth/MinHeightをしているのは、アイテムが0個になった時に幅や高さが0になるとドロップ可能なエリアが極小になって不便だからです。AlignmentにStrechを指定している等でサイズが維持される場合は問題ないですが、そうでない場合は最小サイズを指定しておくのをおすすめします。

[^1]:Behavior\<T>の使い方については[こちら](https://blog.okazuki.jp/entry/20100823/1282561787 "かずきのBlog@hatena - ビヘイビア(Behavior)の作り方")を参照。
[^2]:Target(=元記事のCallback)はOneWayで使用するため。
[^3]:自分自身を返す処理は不要で、単純なGetParentの繰り返しで取得可能です。更にいうと、TreeView以外のみの対応であればドロップ先コントロールは元記事のようにsenderのキャストだけで事足ります（TreeViewの場合は直上のTreeViewItemをItemsControlとして取りたいので、最上位のTreeViewが入ってくるsenderでは対応不可です）。
[^4]:Item数が0のコントロールにドロップした時は、引数=最上位のItemsControlなので親をたどるとItemsControlが取得できません。一方で引数がItemsControlの場合引数を返すという処理を最初に行ってしまうと、TreeViewの場合は目的の一つ下層のItemsControlが取得されてしまいます。