DataGridやListBox内でクリックされたら自身の行を上下に移動するButton

# 概要

DataGridやListBoxで複数のアイテムを表示しているときに、アイテム自身に上下へのボタンをつけたいことがあります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/f2140ba9-efba-a217-a4c3-f592948c9dca.png)

👇 "JIRO"横の⬆をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/72a69feb-5835-c02c-497d-cebf7b317e6e.png)


MVVMでやっている場合は、アイテムごとViewModelに上下移動Commandを用意して、、、となります。
なしの場合はコードビハインドで、上下移動ボタンが押されたItemを検索して、、、となります。
どちらにしても、手間ですし他のコードで使い回せません。

そこで、添付プロパティを使って、Buttonに自身が所属しているItemsControlから行上下移動させる機能を追加します。

Viewだけで完結しているので、ViewModel側には追加作業は必要ありません。
MVVMを使用していない場合も、コードビハインドから呼び出して使えます。
ItemsControlを継承しているコントロールなら使えるので、ItemsControl、DataGrid、ListBox、ListView、ComboBoxでも使えます。

# 方法

以前投稿した、[DataGridやListBox内でクリックされたら自身の行を削除するButton](https://qiita.com/soi/items/94b652850e5ad5394330)を改造して作ります。
削除ボタン機能の一部を共通化して、上下移動ボタンを作ります。

## 添付プロパティ

まず、指定されたオブジェクトが所属している操作するコレクションとオブジェクトのインデックスを検索します。
ここでのコレクションはDataGridのItemsSourceがViewModelのコレクションにBindingされているなら、それが取得されます。

```csharp
/// <summary>
/// 指定されたオブジェクトを含む親コレクションとインデックスを返す
/// </summary>
private static (IList, int) GetParentListAndIndex(DependencyObject elementInItem)
{
    DependencyObject parent = elementInItem;
    var parentTree = new List<DependencyObject> { parent };

    //指定されたオブジェクトのVisualTree上の親を順番に探索し、ItemsControlを探す。
    //ただし、DataGridは中間にいるDataGridCellsPresenterは無視する
    while (parent != null && !(parent is ItemsControl) || parent is DataGridCellsPresenter)
    {
        parent = VisualTreeHelper.GetParent(parent);
        parentTree.Add(parent);
    }
    if (!(parent is ItemsControl itemsControl))
        return (null, -1);

    //ItemsControlの行にあたるオブジェクトを探索履歴の後ろから検索
    var item = parentTree
        .LastOrDefault(x => itemsControl.IsItemItsOwnContainer(x));

    //削除するIndexを取得
    int removeIndex = itemsControl.ItemContainerGenerator?.IndexFromContainer(item)
        ?? -1;

    //Bindingしていた場合はItemsSource、違うならItemsから削除する
    IList targetList = ((itemsControl.ItemsSource as IList) ?? itemsControl.Items);

    return (targetList, removeIndex);
}
```

次に、指定されたオブジェクトを含む行を上下に移動するメソッドを定義します。
コードビハインドを使用する場合は、このメソッドをButtonのクリックイベントから呼び出しても使えます。

```csharp
/// <summary>
/// 指定されたオブジェクトを含む行のIndexを増やす = 下の行に移動する
/// </summary>
public static void IncrementItemFromParent(DependencyObject elementInItem)
{
    var (targetList, index) = GetParentListAndIndex(elementInItem);

    if (targetList == null || index < 0)
        return;

    //最後の行だったら何もしない
    if ((index + 1) >= targetList.Count)
        return;

    //一度削除して、1つ大きいIndexに入れ直す
    var targetElement = targetList[index];
    targetList?.RemoveAt(index);
    targetList?.Insert(index + 1, targetElement);
}

/// <summary>
/// 指定されたオブジェクトを含む行のIndexを減らす = 上の行に移動する
/// </summary>
public static void DecrementItemFromParent(DependencyObject elementInItem)
{
    var (targetList, index) = GetParentListAndIndex(elementInItem);

    if (targetList == null || index < 0)
        return;

    //最初の行だったら何もしない
    if (index <= 0)
        return;

    //一度削除して、1つ少ないのIndexに入れ直す
    var targetElement = targetList[index];
    targetList?.RemoveAt(index);
    targetList?.Insert(index - 1, targetElement);
}

/// <summary>
/// 指定されたオブジェクトを含む行を親のItemsControlから削除する
/// </summary>
public static void RemoveItemFromParent(DependencyObject elementInItem)
{
    var (targetList, index) = GetParentListAndIndex(elementInItem);

    if (targetList == null || index < 0)
        return;

    targetList?.RemoveAt(index);
}
```

そして、Buttonのクリックイベントでこのメソッドを呼ぶ添付プロパティを用意します。

```csharp
#region RemoveItem添付プロパティ
public static bool GetRemoveItem(DependencyObject obj) => (bool)obj.GetValue(RemoveItemProperty);
public static void SetRemoveItem(DependencyObject obj, bool value) => obj.SetValue(RemoveItemProperty, value);
public static readonly DependencyProperty RemoveItemProperty =
    DependencyProperty.RegisterAttached("RemoveItem", typeof(bool), typeof(DataGridOperation),
        new PropertyMetadata(false, (d, e) => OnPropertyChanged(d, e, RemoveItem)));
private static void RemoveItem(object sender, RoutedEventArgs e) => RemoveItemFromParent(sender as DependencyObject);
#endregion

#region IncrementItem添付プロパティ
public static bool GetIncrementItem(DependencyObject obj) => (bool)obj.GetValue(IncrementItemProperty);
public static void SetIncrementItem(DependencyObject obj, bool value) => obj.SetValue(IncrementItemProperty, value);
public static readonly DependencyProperty IncrementItemProperty =
    DependencyProperty.RegisterAttached("IncrementItem", typeof(bool), typeof(DataGridOperation),
        new PropertyMetadata(false, (d, e) => OnPropertyChanged(d, e, IncrementItem)));

private static void IncrementItem(object sender, RoutedEventArgs e) => IncrementItemFromParent(sender as DependencyObject);
#endregion

#region DecrementItem添付プロパティ
public static bool GetDecrementItem(DependencyObject obj) => (bool)obj.GetValue(DecrementItemProperty);
public static void SetDecrementItem(DependencyObject obj, bool value) => obj.SetValue(DecrementItemProperty, value);
public static readonly DependencyProperty DecrementItemProperty =
    DependencyProperty.RegisterAttached("DecrementItem", typeof(bool), typeof(DataGridOperation),
        new PropertyMetadata(false, (d, e) => OnPropertyChanged(d, e, DecrementItem)));
private static void DecrementItem(object sender, RoutedEventArgs e) => DecrementItemFromParent(sender as DependencyObject);
#endregion

private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e, RoutedEventHandler actionClick)
{
    if (!(d is ButtonBase button))
        return;

    if (!(e.NewValue is bool b))
        return;

    if (b)
        button.Click += actionClick;
    else
        button.Click -= actionClick;
}
```

## 使用方法
ViewModel側にこんなプロパティがあるとします。

```csharp
public ObservableCollection<string> Names { get; } = new ObservableCollection<string>(new[] { "TARO", "JIRO", "SABRO" });
```

それに対して、ViewではDataGridで上記の`Names`プロパティにBindingしています。

```xml:DataGrid(MVVM)
<DataGrid
   AutoGenerateColumns="False"
   ItemsSource="{Binding Names}">
   <DataGrid.Columns>
      <DataGridTextColumn Binding="{Binding}" />
      <DataGridTemplateColumn>
         <DataGridTemplateColumn.CellTemplate>
            <DataTemplate>
               <StackPanel Orientation="Horizontal">
                  <Button local:DataGridOperation.IncrementItem="True" Content="⇩" />
                  <Button local:DataGridOperation.DecrementItem="True" Content="⬆" />
                  <Button local:DataGridOperation.RemoveItem="True" Content="✖" />
               </StackPanel>
            </DataTemplate>
         </DataGridTemplateColumn.CellTemplate>
      </DataGridTemplateColumn>
   </DataGrid.Columns>
</DataGrid>
```

ListBoxの場合は以下です。

```xml:ListBox(MVVM)
<ListBox
   ItemsSource="{Binding Names}">
   <ListBox.ItemTemplate>
      <DataTemplate>
         <StackPanel Orientation="Horizontal">
            <TextBlock Width="100" Text="{Binding}" />
            <Button local:DataGridOperation.IncrementItem="True" Content="⇩" />
            <Button local:DataGridOperation.DecrementItem="True" Content="⬆" />
            <Button local:DataGridOperation.RemoveItem="True" Content="✖" />
         </StackPanel>
      </DataTemplate>
   </ListBox.ItemTemplate>
</ListBox>
```

添付プロパティを使用せず、コードビハインドから使用する場合は、以下のようになります

```xml:DataGrid(Plane)
<DataGrid>
   <DataGrid.Columns>
      <DataGridTextColumn Binding="{Binding Text}" />
      <DataGridTemplateColumn>
         <DataGridTemplateColumn.CellTemplate>
            <DataTemplate>
               <StackPanel Orientation="Horizontal">
                  <Button Click="IncrementButton_Click" Content="⇩" />
                  <Button Click="DecrementButton_Click" Content="⬆" />
                  <Button Click="XButton_Click" Content="✖" />
               </StackPanel>
            </DataTemplate>
         </DataGridTemplateColumn.CellTemplate>
      </DataGridTemplateColumn>
   </DataGrid.Columns>
   <TextBlock Text="AAA" />
   <TextBlock Text="BBB" />
   <TextBlock Text="CCC" />
</DataGrid>
```

コードビハインドに以下を追加します。

```csharp
private void IncrementButton_Click(object sender, RoutedEventArgs e)
{
    if (sender is DependencyObject dObj)
        DataGridOperation.IncrementItemFromParent(dObj);
}
private void DecrementButton_Click(object sender, RoutedEventArgs e)
{
    if (sender is DependencyObject dObj)
        DataGridOperation.DecrementItemFromParent(dObj);
}
private void XButton_Click(object sender, RoutedEventArgs e)
{
    if (sender is DependencyObject dObj)
        DataGridOperation.RemoveItemFromParent(dObj);
}
```

# 注意点
DataGridなどで並び替えしていると、正しく動きません。Index算出は並び替え後のIndexですが、削除・移動時はデフォルトの並びでのIndexを指定する必要があるためです。

View側での変更をViewModelに伝えるため、ItemsSourceのBindingはTwo-Wayにする必要があります。
つまり、ReadOnlyなコレクションがBindingされていた場合は使えません。

# 環境
VisualStudio2019
.NET Core 3.1
C#8