WPFのMVVMでコンテキストメニューを動的に生成する方法

C# コンピュータ
C#

WPFでコンテキストメニューの項目を動的に生成する方法を試して見ました。
コンテキストメニューは親コントロールがあるわけでは無いので、DataContextを直接指定する必要があるとのこと。
メニューのヘッダーは表示するのにコマンドが実行されないなど、結構理不尽にも思えるトラブルにも遭遇しましたが、
何とか「ListViewで右クリック時、バインドされたメニューを動的に表示する」までたどり着きました。

動作イメージ

1.起動するとリストビューが表示される。

2.右クリックでコンテキストメニューが表示される。

3.メッセージボックス

ソースコード

ファイル名:ListViewItem.cs

using System.ComponentModel;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Reactive.Disposables;

namespace ListViewOnContextMenu01;
public class ListViewItem: INotifyPropertyChanged, IDisposable
{
#region
    // INotifyPropertyChanged
    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    // IDisposable
    private CompositeDisposable Disposable { get; } = [];
    public void Dispose() => Disposable.Dispose();
#endregion
    public string Name { get; set; } = "";
    public ReactiveCollection<MenuItemViewModel> ContextMenuItems { get; set; } = [];

    // コンストラクタ
    public ListViewItem()
    {
        ContextMenuItems.AddTo(Disposable);
    }
}

ファイル名:MainWindow.xaml

<Window x:Class="ListViewOnContextMenu01.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:ListViewOnContextMenu01"
    mc:Ignorable="d"
    Title="Title"
    Height="450"
    Width="800"
    FontSize="16"
    xmlns:i="clr-namespace:Microsoft.Xaml.Behaviors;assembly=Microsoft.Xaml.Behaviors">
  <Window.DataContext>
    <local:MainWindowViewModel />
  </Window.DataContext>
  <i:Interaction.Behaviors>
    <local:ViewModelCleanupBehavior />
  </i:Interaction.Behaviors>
  <Grid>
        <ListView
            SelectedItem="{Binding SelectedItem.Value}"
            ItemsSource="{Binding Items}">
<!-- リストビュー コンテキストメニュー から -->
            <ListView.ItemContainerStyle>
                <Style TargetType="{x:Type ListViewItem}">
                    <Setter Property="ContextMenu">
                        <Setter.Value>
                            <ContextMenu
                                DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"
                                ItemsSource="{Binding ContextMenuItems}">
                                <ContextMenu.ItemContainerStyle>
                                    <Style TargetType="MenuItem">
                                        <Setter Property="Header" Value="{Binding Header}" />
                                        <Setter Property="Command" Value="{Binding Command}" />
                                    </Style>
                                </ContextMenu.ItemContainerStyle>
                            </ContextMenu>
                        </Setter.Value>
                    </Setter>
                </Style>
            </ListView.ItemContainerStyle>
<!-- リストビュー コンテキストメニュー ここまで -->
            <ListView.View>
                <GridView>
                    <GridViewColumn Width="300" Header="名前">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <StackPanel Orientation="Horizontal">
                                    <TextBlock Width="Auto" TextAlignment="Left" Text="{Binding Name}" />
                                </StackPanel>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>
  </Grid>
</Window>

ファイル名:MainWindowViewModel.cs

using System.Diagnostics;
using System.ComponentModel;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Reactive.Disposables;
using System.Windows;

namespace ListViewOnContextMenu01;
public class MainWindowViewModel : INotifyPropertyChanged, IDisposable
{
#region
    // INotifyPropertyChanged
    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    // IDisposable
    private CompositeDisposable Disposable { get; } = [];
    public void Dispose() => Disposable.Dispose();
#endregion
    /**************************************************************************
    * プロパティ
    **************************************************************************/
    public ReactiveCollection<ListViewItem> Items { get; set; } = [];
    public ReactiveProperty<ListViewItem> SelectedItem { get; set; } = new();
    public MainWindowViewModel()
    {
        Items.AddTo(Disposable);
        SelectedItem.AddTo(Disposable);

        List<string> names = ["アイテム1", "アイテム2", "アイテム3"];

        foreach(string name in names)
        {
            ListViewItem item = new()
            {
                Name = name,
            };
            item.ContextMenuItems.Add(new MenuItemViewModel(
                "メッセージボックス",
                () => { Execute(); }
            ));

            Items.Add( item );

        }
    }
    public void Execute()
    {
        if (SelectedItem.Value is null) return;

        MessageBox.Show($"{SelectedItem.Value.Name}", "選択されているアイテム");
    }
}

ファイル名:MenuItemViewModel.cs

using System.ComponentModel;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Reactive.Disposables;

namespace ListViewOnContextMenu01;

public class MenuItemViewModel: INotifyPropertyChanged, IDisposable
{
#region
    // INotifyPropertyChanged
    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    // IDisposable
    private CompositeDisposable Disposable { get; } = [];
    public void Dispose() => Disposable.Dispose();
    #endregion
    public string Header { get; set; }
    public ReactiveCommand Command { get; } = new();

    // コンストラクタ
    public MenuItemViewModel(string header, Action execute)
    {
        Command.AddTo(this.Disposable);
        Header = header;
        Command.Subscribe(_ => execute());
    }
}

ファイル名:ViewModelCleanupBehavior.cs

using Microsoft.Xaml.Behaviors;
using System.Windows;

namespace ListViewOnContextMenu01;
public class ViewModelCleanupBehavior : Behavior<Window>
{
    protected override void OnAttached()
    {
        base.OnAttached();
        this.AssociatedObject.Closed += this.WindowClosed;
    }

    private void WindowClosed(object? sender, EventArgs e)
    {
        (this.AssociatedObject.DataContext as IDisposable)?.Dispose();
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        this.AssociatedObject.Closed -= this.WindowClosed;
    }
}

解説

ListView上でコンテキストメニューが表示されるようにXAMLを記述します。
コンテキストメニューはVisualTreeに含まれないので、DataContextにPlacementTarget.DataContextを指定することでListViewItemをデータソースにする仕掛けになっています。

DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"

データソースとなる、ListViewItemにコンテキストメニューをプロパティとして持たせます。

 public ReactiveCollection<MenuItemViewModel> ContextMenuItems { get; set; } = [];

動的にメニュー項目を登録する目的で、コレクションにしています。

ListViewのアイテムを追加する際にコンテキストメニューの項目を生成し追加します。

            item.ContextMenuItems.Add(new MenuItemViewModel(
                "メッセージボックス",
                () => { Execute(); }
            ));

まとめ

コンテキストメニュー項目を動的に追加する方法を知っておけば、状況に応じて必要な項目のみを表示するなど、細かな制御が出来ると思います。

コメント