DataGridやListBox内でクリックされたら自身の行を削除するButton


概要

DataGridやListBoxで複数のアイテムを表示しているときに、アイテム自身に削除ボタンをつけたいことがあります。


👇 "JIRO"を削除

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

そこで、添付プロパティを使って、Buttonに自身が所属しているItemsControlから削除する機能を追加します。

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

方法

添付プロパティ

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

/// <summary>
/// 指定されたオブジェクトを含む行を親のItemsControlから削除する
/// </summary>
public static void RemoveItemFromParent(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;

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

    int? removeIndex = itemsControl.ItemContainerGenerator?.IndexFromContainer(item);

    if (removeIndex == null || removeIndex < 0)
        return;

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

switch (targetList)
            {
                case IList il:
                    il.RemoveAt(index);
                    return;
                case IEditableCollectionView iECV:
                    iECV.RemoveAt(index);
                    return;
            }
}

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

#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(MyExt), 
      new PropertyMetadata(default(bool), OnRemoveItemChanged));

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

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

    if (b)
        button.Click += RemoveItem;
    else
        button.Click -= RemoveItem;
}
private static void RemoveItem(object sender, RoutedEventArgs e) => RemoveItemFromParent(sender as DependencyObject);
#endregion

使用方法

ViewModel側にこんなプロパティがあるとします。

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

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

DataGrid(MVVM)
<DataGrid
   AutoGenerateColumns="False"
   ItemsSource="{Binding Names}">
   <DataGrid.Columns>
      <DataGridTextColumn Binding="{Binding}" />
      <DataGridTemplateColumn>
         <DataGridTemplateColumn.CellTemplate>
            <DataTemplate>
               <StackPanel>
                  <Button local:MyExt.RemoveItem="True" Content="✖" />
               </StackPanel>
            </DataTemplate>
         </DataGridTemplateColumn.CellTemplate>
      </DataGridTemplateColumn>
   </DataGrid.Columns>
</DataGrid>

ListBoxの場合は以下です。

ListBox(MVVM)
<ListBox
   ItemsSource="{Binding Names}">
   <ListBox.ItemTemplate>
      <DataTemplate>
         <StackPanel Orientation="Horizontal">
            <TextBlock Width="100" Text="{Binding}" />
            <Button local:MyExt.RemoveItem="True" Content="✖" />
         </StackPanel>
      </DataTemplate>
   </ListBox.ItemTemplate>
</ListBox>

MVVMを使用しない場合は、以下のようになります

DataGrid(Plane)
<DataGrid>
   <DataGrid.Columns>
      <DataGridTextColumn Binding="{Binding Text}" />
      <DataGridTemplateColumn>
         <DataGridTemplateColumn.CellTemplate>
            <DataTemplate>
               <Button Click="XButton_Click" Content="X" />
               <!--<Button Content="X" local:MyExt.RemoveItem="True">-->
            </DataTemplate>
         </DataGridTemplateColumn.CellTemplate>
      </DataGridTemplateColumn>
   </DataGrid.Columns>
   <TextBlock Text="AAA" />
   <TextBlock Text="BBB" />
   <TextBlock Text="CCC" />
</DataGrid>

添付プロパティを使用せず、クリックイベントのコードビハインドから呼び出す場合は、上記Xamlのコメント部分を解除した上で、コードビハインドに以下を追加します。

private void XButton_Click(object sender, RoutedEventArgs e)
{
    if (sender is DependencyObject dObj)
        MyExt.RemoveItemFromParent(dObj);
}

注意点

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

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

環境

VisualStudio2019
.NET Core 3.1
C#8