1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WPF - カスタムコントール - ContextMenu で一覧選択

Last updated at Posted at 2025-04-27

はじめに

WPF カスタムコントールを用いることで、手軽に標準コントールの外観/挙動を変更することができます。
本記事では、ContextMenu を用いた一覧選択について記載します。

WPF カスタムコントール については下記記事もあります

テスト環境

ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。

  • WPF - .NET Framework 4.8
  • WPF - .NET 8

記載したソースコードは .NET 8 ベースとしています。
.NET Framework 4.8 の場合は、コメントで記載している null 許容参照型の明示 ? を削除してください。

Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。

サンプル

対象

ComboBox - DropDownList 一覧選択相当の UI を、Button - ContextMenu で実現することを目指します。

ContextMenu はマウスカーソル位置に表示されるため、キー操作時に UI として不具合が発生する可能性があります。
具体的には、トリガーとなる Button から離れた位置にマウスカーソルがある状態で、Focus が Buttun にあり Enter キーを押下すると、Button から離れたマウスカーソル位置に選択肢が表示されてしまいます。

マウス もしくは タッチ操作が前提であれば問題はありませんが、キー操作でクリックした場合には、マウス位置補正が必要となります。(詳細は末尾「不具合に対する調整」)

構成

ContextMenu を用いて、ComboBox 相当の UI を実現するために、下記3つのカスタムコントールを用意します。

カスタムコントール 基底クラス
PopupSelectButton Button
PopupSelectMenu ContextMenu
PopupSelectItem MenuItem

この3つのカスタムコントールを以下のように配置する形態とします。

<local:PopupSelectButton x:Name="btnGengo" Width="100" Height="30"
    HorizontalAlignment="Left">
  <local:PopupSelectButton.ContextMenu>
    <local:PopupSelectMenu>
      <local:PopupSelectItem ItemValue="明治"/>
      <local:PopupSelectItem ItemValue="大正"/>
      <local:PopupSelectItem ItemValue="昭和"/>
      <local:PopupSelectItem ItemValue="平成"/>
      <local:PopupSelectItem ItemValue="令和"/>
      </local:PopupSelectMenu>
   </local:PopupSelectButton.ContextMenu>
</local:PopupSelectButton>
  • PopupSelectButton 配下に PopupSelectMenu を配置して、PopupSelectButton.Click イベントで PopupSelectMenu を表示
  • PopupSelectMenu 配下に選択肢を PopupSelectItem で配置

外観

DockPanel で最下端に配置した実行結果を提示します。

sample.png

サンプル プロジェクト

カスタムコントール追加

Visual Studio で WPF アプリケーションのプロジェクト WpfApp1 を作成して、ソリューションエクスプローラで Controls というサブフォルダを用意します。
この Controls というサブフォルダを選択して、追加 - 新しい項目 を選択します。

vs-01.png

新しい項目追加で カスタムコントール(WPF)を選択して、PopupSelectButton.cs を作成します。

vs-02.png

カスタムコントールを追加すると Theme\Generic.xaml が自動的に追加されます。
同様の手順で、PopupSelectMenu.csPopupSelectItem.cs を作成します。

vs-03.png

namespace 修正

カスタムコントールを Controls 配下に配置したので、下記2ファイルの local namespece を修正します。

  • Themes\Generic.xaml
  • MainWindow.xaml
xmlns:local="clr-namespace:WpfApp1"
 ↓
xmlns:local="clr-namespace:WpfApp1.Controls"

サンプルコード

PopupSelectItem

選択肢として利用する PopupSelectItem のポイント

  • xaml でプロパティ既定値を設定
  • xaml - ControlTemplate で Border - ContentPresenter として外観を設定
  • xaml - ControlTemplate.Triggers で IsChecked などの状態に対する外観を設定
  • PopupSelectMenu.SelectedIndex に対応させるプロパティとして、ItemIndex 用意
    • ItemIndex はユーザ指定ではなく、PopupSelectMenu.OnApplyTemplate で自動採番
  • MenuItem のラベル表示は Header プロパティですが、上記 ItemIndex とペアとなる ItemValue プロパティを追加して、xaml - ContentPresenter で Content にバインド
  • ItemIndex, ItemValue は DependencyProperty とする

配色は Colors 名称を利用していますが、SystemColors を利用することも可能です。
※以降のカスタムコントールも同様です。

Generic.xaml
<!-- PopupSelectItem -->
<Style TargetType="{x:Type local:PopupSelectItem}" 
    BasedOn="{StaticResource {x:Type MenuItem}}">
  <!-- プロパティ既定値 -->
  <Setter Property="Background" Value="Gainsboro" />
  <Setter Property="BorderBrush" Value="Black" />
  <Setter Property="Foreground" Value="Black" />
  <Setter Property="BorderThickness" Value="1" />
  <Setter Property="Height" Value="30"/>
  <Setter Property="MinWidth" Value="80"/>
  <!-- コントール外観 -->
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type local:PopupSelectItem}">
        <Border Background="{TemplateBinding Background}" 
            BorderBrush="{TemplateBinding BorderBrush}" 
            BorderThickness="{TemplateBinding BorderThickness}"
            Height="{TemplateBinding Height}">
          <ContentPresenter Content="{TemplateBinding ItemValue}" 
              Margin="5,0,0,0" VerticalAlignment="Center"/>
        </Border>
        <ControlTemplate.Triggers>
          <!-- 選択されている場合 -->
          <Trigger Property="IsChecked" Value="True">
            <Setter Property="Background" Value="LightBlue"/>
          </Trigger>
          <!-- ディセーブルの場合 -->
          <Trigger Property="IsEnabled" Value="False">
            <Setter Property="Background" Value="Lavender" />
            <Setter Property="BorderBrush" Value="Gray" />
            <Setter Property="Foreground" Value="White"/>
          </Trigger>
          <!-- キーボードフォーカスがある場合 -->
          <Trigger Property="IsKeyboardFocused" Value="True">
            <Setter Property="Background" Value="LightSteelBlue" />
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>

  <!-- キー操作を考慮しないケースでは下記を設定
  <Setter Property="Focusable" Value="False"/>
  <Setter Property="IsTabStop" Value="False"/>
  -->
</Style>
namespace WpfApp1.Controls
{
  public class PopupSelectItem : MenuItem
  {
    // 依存関係プロパティ
    public static readonly DependencyProperty ItemIndexProperty =
        DependencyProperty.Register("ItemIndex", typeof(int),
        typeof(PopupSelectItem), new PropertyMetadata(-1));
    public static readonly DependencyProperty ItemValueProperty =
        DependencyProperty.Register("ItemValue", typeof(string),
        typeof(PopupSelectItem), new PropertyMetadata(string.Empty));

    // プロパティ
    public int ItemIndex
    {
      get => (int)GetValue(ItemIndexProperty);
      set => SetValue(ItemIndexProperty, value);
    }
    public string ItemValue
    {
      get => (string)GetValue(ItemValueProperty);
      set => SetValue(ItemValueProperty, value);
    }

    // 静的コンストラクタ - クラス全体の初期化で1度だけ呼び出される
    static PopupSelectItem()
    {
      // DefaultStyleKeyの設定
      DefaultStyleKeyProperty.OverrideMetadata(
          typeof(PopupSelectItem),
          new FrameworkPropertyMetadata(typeof(PopupSelectItem)));
    }
    // インスタンス コンストラクタ - インスタンスごとに呼び出される
    public PopupSelectItem()
    {

    }
    // テンプレート適用イベントハンドラ
    public override void OnApplyTemplate()
    {
      base.OnApplyTemplate();
      this.Click += this.OnClick;
    }
    // .NET Framework 時は object? の ? 不要
    private void OnClick(object? sender, RoutedEventArgs e)
    {
      if (this.Parent is PopupSelectMenu menu)
      {
        // 上位 PopupSelectMenu に自身の ItemIndex 通知
        menu.SetSelectedIndex(this.ItemIndex);
      }
    }
  }
}

PopupSelectMenu

選択肢を一覧表示する PopupSelectMenu のポイント

  • xaml でプロパティ既定値を設定
    • PopupSelectMenu で Opacity を設定すれば、配下 PopupSelectItem にも反映
  • PopupSelectMenu.OnApplyTemplate で、配下 PopupSelectItem.ItemIndex を自動採番
  • PopupSelectMenu.SelectItem で、配下 PopupSelectItem のうちひとつを選択状態とする
    • 後述「不具合に対する調整」を確認
Generic.xaml
<!-- PopupSelectMenu -->
<Style TargetType="{x:Type local:PopupSelectMenu}"
    BasedOn="{StaticResource {x:Type ContextMenu}}">
  <!-- プロパティ既定値 -->
  <Setter Property="Opacity" Value="0.85"/>
  <!-- コントール外観 -->
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type local:PopupSelectMenu}">
        <Border>
          <StackPanel IsItemsHost="True"/>
        </Border>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>
PopupSelectMenu.cs
namespace WpfAppNet.Controls
{
  public class PopupSelectMenu : ContextMenu
  {
    // 静的コンストラクタ - クラス全体の初期化で1度だけ呼び出される
    static PopupSelectMenu()
    {
      // DefaultStyleKeyの設定
      DefaultStyleKeyProperty.OverrideMetadata(
          typeof(PopupSelectMenu), 
          new FrameworkPropertyMetadata(typeof(PopupSelectMenu)));
    }
    // インスタンス コンストラクタ - インスタンスごとに呼び出される
    public PopupSelectMenu()
    {

    }
    // テンプレート適用イベントハンドラ
    public override void OnApplyTemplate()
    {
      base.OnApplyTemplate();
      this.Opened += OnOpened;

      // このタイミングでは、テンプレート内要素(子コントール)にアクセス可能
      // 子コントール PopupSelectItem.ItemIndex に 0 から序数設定
      int index = 0;
      foreach (PopupSelectItem item in
          LogicalTreeHelper.GetChildren(this).OfType<PopupSelectItem>())
      {
        item.ItemIndex = index++;
      }
    }
    // .NET Framework 時は object? の ? 不要
    private void OnOpened(object? sender, RoutedEventArgs e)
    {
      if (sender is PopupSelectMenu menu
       && menu.PlacementTarget is PopupSelectButton button)
      {
        SelectItem(button.SelectedIndex);
      }
    }
    // PopupMenuButton の SelectedIndex を変更
    public void SetSelectedIndex(int index)
    {
      if (this.PlacementTarget is PopupSelectButton button)
      {
        button.SelectedIndex = index;
      }
    }
    // PopupSelectItem 選択状態変更
    public void SelectItem(int targetIndex)
    {
      // 選択状態変更
      int index = 0;
      foreach (PopupSelectItem item in this.Items)
      {
        item.IsChecked = (targetIndex == index++);
        if (item.IsChecked)
        {
          // TODO - マウスカーソル移動(後述)
          
          item.Focus();  //フォーカス設定
        }
      }
    }
    // PopupSelectItem 表示データ取得
    public string GetItemValue(int index)
    {
      string text = string.Empty;
      if (this.HasItems && index >= 0 && index < this.Items.Count
       && this.Items[index] is PopupSelectItem item)
      {
        text = item.ItemValue?.ToString() ?? string.Empty; 
      }
      return text;
    }
  }
}

PopupSelctButton

PopupSelectMenu を表示する PopupSelctButton のポイント

  • xaml でプロパティ既定値を設定
  • xaml - ControlTemplate で Gird を用いて「 選択値 | ▼ 」という外観を構築
  • xaml - ControlTemplate.Triggers で IsEnabled 状態に対する外観を設定
  • 現在の選択値管理用に SelectedIndex を DependencyProperty として追加
    • 値変更ハンドラとして OnSelectedIndexChanged 指定
  • SelectedIndexChanged イベントハンドラを登録を可能とする
    • OnSelectedIndexChanged で、SelectedIndexChanged に登録されている処理を実行
  • Click 処理で PopupSelectMenu を表示
Generic.xaml
<!-- PopupSelectButton -->
<Style TargetType="{x:Type local:PopupSelectButton}"
    BasedOn="{StaticResource {x:Type Button}}">
  <!-- プロパティ既定値 -->
  <Setter Property="MinHeight" Value="25"/>
  <Setter Property="MinWidth" Value="100"/>
  <!-- コントール外観 -->
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type local:PopupSelectButton}">
        <Border Width="{TemplateBinding Width}"
            Height="{TemplateBinding Height}"
            Background="{TemplateBinding Background}" 
            BorderBrush="{TemplateBinding BorderBrush}" 
            BorderThickness="{TemplateBinding BorderThickness}">
          <Grid>
            <!-- 3列レイアウト 「 選択値 | ▼ 」 -->
            <Grid.ColumnDefinitions>
              <ColumnDefinition Width="*"/>
              <ColumnDefinition Width="2px"/>
              <ColumnDefinition Width="15px"/>
            </Grid.ColumnDefinitions>
            <ContentPresenter Grid.Column="0" 
                HorizontalAlignment="Center" VerticalAlignment="Center"/>
            <Border Grid.Column="1" BorderThickness="1" Width="1" Margin="1,0,0,0"
                BorderBrush="{TemplateBinding BorderBrush}"/>
            <TextBlock Grid.Column="2" Text="▼" FontSize="13"
                HorizontalAlignment="Center" VerticalAlignment="Center"/>
          </Grid>
        </Border>
        <ControlTemplate.Triggers>
          <!-- ディセーブルの場合 -->
          <Trigger Property="IsEnabled" Value="False">
            <Setter Property="Background" Value="Lavender" />
            <Setter Property="BorderBrush" Value="Gray" />
            <Setter Property="Foreground" Value="DarkGray"/>
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>

  <!-- キー操作を考慮しないケースでは下記を設定
  <Setter Property="Focusable" Value="False"/>
  <Setter Property="IsTabStop" Value="False"/>
  -->
</Style>
PopupSelctButton.cs
namespace WpfAppNet.Controls
{
  public class PopupSelectButton : Button
  {
    // 依存関係プロパティ
    public static readonly DependencyProperty SelectedIndexProperty =
        DependencyProperty.Register("SelectedIndex", typeof(int),
        typeof(PopupSelectButton), new PropertyMetadata(-1, OnSelectedIndexChanged));

    // プロパティ
    public int SelectedIndex
    {
      get => (int)GetValue(SelectedIndexProperty);
      set => SetValue(SelectedIndexProperty, value);
    }

    // イベント - .NET Framework 時は EventHandler<...>? の ? 不要
    public event EventHandler<SelectionChangedEventArgs>? SelectedIndexChanged;
    private static void OnSelectedIndexChanged(DependencyObject obj,
                                               DependencyPropertyChangedEventArgs e)
    {
      if (obj is PopupSelectButton button)
      {
        var oldIndex = (int)e.OldValue;
        var newIndex = (int)e.NewValue;
        // PopupSelectMenu
        if (button.ContextMenu is PopupSelectMenu menu && menu.HasItems)
        {
          // 表示更新
          button.Content = menu.GetItemValue(newIndex);
          // 選択状態変更
          if (menu.IsOpen)
          {
            menu.SelectItem(newIndex);
          }
        }
        // SelectedIndexChanged イベント
        var args = new SelectionChangedEventArgs(
            Selector.SelectionChangedEvent,
            new[] { oldIndex },
            new[] { newIndex });
        button.SelectedIndexChanged?.Invoke(button, args);
      }
    }
    // 静的コンストラクタ - クラス全体の初期化で1度だけ呼び出される
    static PopupSelectButton()
    {
      // DefaultStyleKeyの設定
      DefaultStyleKeyProperty.OverrideMetadata(
          typeof(PopupSelectButton),
          new FrameworkPropertyMetadata(typeof(PopupSelectButton)));
    }
    // インスタンス コンストラクタ - インスタンスごとに呼び出される
    public PopupSelectButton()
    {

    }
    // テンプレート適用イベントハンドラ
    public override void OnApplyTemplate()
    {
      base.OnApplyTemplate();
      this.Click += OnClick;

      // xaml で SelectedIndex 初期値設定した場合に対処
      if (this.ContextMenu is PopupSelectMenu menu)
      {
        this.Content = menu.GetItemValue(this.SelectedIndex);
      }
    }
    // .NET Framework 時は object? の ? 不要
    private void OnClick(object? sender, RoutedEventArgs e)
    {
      if (sender is PopupSelectButton button
       && button.ContextMenu is PopupSelectMenu menu && menu.HasItems)
      {
        // PopupSelectMenu 表示
        menu.PlacementTarget = this;
        menu.IsOpen = true;
      }
    }
  }
}

メイン画面

メイン画面の xaml、コードビハインドも掲載しておきます。

MainWindow.xaml
<Window x:Class="WpfApp1.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:WpfApp1.Controls"
  mc:Ignorable="d"
  Title="MainWindow" Height="300" Width="500">
  <DockPanel LastChildFill="False">
    <local:PopupSelectButton x:Name="btnGengo" Width="100" Height="30"
        HorizontalAlignment="Left" SelectedIndex="2"
        DockPanel.Dock="Bottom">
      <local:PopupSelectButton.ContextMenu>
        <local:PopupSelectMenu>
          <local:PopupSelectItem ItemValue="明治"/>
          <local:PopupSelectItem ItemValue="大正"/>
          <local:PopupSelectItem ItemValue="昭和"/>
          <local:PopupSelectItem ItemValue="平成"/>
          <local:PopupSelectItem ItemValue="令和"/>
        </local:PopupSelectMenu>
      </local:PopupSelectButton.ContextMenu>
    </local:PopupSelectButton>
  </DockPanel>
</Window>
MainWindow.xaml.cs
public partial class MainWindow : Window
{
  public MainWindow()
  {
    InitializeComponent();
    btnGengo.SelectedIndexChanged += PopupSelectButton_SelectedIndexChanged;
  }
  // .NET Framework 時は object? の ? 不要
  private void PopupSelectButton_SelectedIndexChanged(object? sender,
                                                      SelectionChangedEventArgs e)
  {
    if (sender is PopupSelectButton button)
    {
      if (e.AddedItems.Count > 0 && e.AddedItems[0] is int value)
      {
        MessageBox.Show($"{button.Name} Value={value}");
      }
      else
      {
        MessageBox.Show($"{button.Name} value is empty");
      }
    }
  }
}

不具合に対する調整

PopupSelctButton

冒頭に記載した、マウスカーソルが離れた位置で、キー操作クリック不具合の対応です。
クリック操作がマウスかキーかを判定するために、PreviewMouseDown、PreviewKeyDown イベントを利用します。

private bool isMouseClick = false;
// .NET Framework 時は object? の ? 不要
private void OnPreviewMouseDown(object? sender, MouseButtonEventArgs e)
{
  isMouseClick = true;
}
// .NET Framework 時は object? の ? 不要
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
  isMouseClick = false;
}

WIN32API を用いて、対象コントール中央位置にマウスを移動させるメソッドを作成します。
PopupSelctButton 以外での利用も考慮して、Controls 下に新規クラスで CommonOperations.cs を追加して、ここに実装します。

CommonOperations.cs
namespace WpfAppNet.Controls
{
  public class CommonOperations
  {
    // マウスカーソル移動
    public static void MoveCursorToControl(Control control)
    {
      // 対象の位置を取得
      var point = control.PointToScreen(new System.Windows.Point(0, 0));
      int x = (int)(point.X + control.RenderSize.Width / 2);  // 対象の中央X座標
      int y = (int)(point.Y + control.RenderSize.Height / 2); // 対象の中央Y座標

      // マウスカーソルを移動
      NativeMethods.SetCursorPos(x, y);
    }
    // WIN32API
    private static class NativeMethods
    {
      [DllImport("user32.dll")]
      public static extern bool SetCursorPos(int X, int Y);
    }
  }
}

WIN32API を直接記述していますが、Microsoft が提供している CsWin32 を利用するという手もあります。

これらのソースを対処した上、既存ソースを修正します。

// テンプレート適用イベントハンドラ
public override void OnApplyTemplate()
{
  base.OnApplyTemplate();
  this.Click += OnClick;
  this.PreviewMouseDown += OnPreviewMouseDown;  // 不具合対応
  this.PreviewKeyDown += OnPreviewKeyDown;      // 不具合対応

  // xaml で SelectedIndex 初期値設定した場合に対処
  if (this.ContextMenu is PopupSelectMenu menu)
  {
    this.Content = menu.GetItemValue(this.SelectedIndex);
  }
}
// .NET Framework 時は object? の ? 不要
private void OnClick(object? sender, RoutedEventArgs e)
{
  if (sender is PopupSelectButton button
   && button.ContextMenu is PopupSelectMenu menu && menu.HasItems)
  {
    // 不具合対応 - キー操作時
    if (!isMouseClick)
    {
      // マウスカーソル移動
      CommonOperations.MoveCursorToControl(button);
    }
    // PopupSelectMenu 表示
    menu.PlacementTarget = this;
    menu.IsOpen = true;
  }
}

PopupSelectMenu

PopupSelectMenu の OnOpend で、現在選択されている PopupSelectItem を Focus していますが、一覧選択表示時に、マウスカーソルが選択対象以外の PopupSelectItem 上に入ってしまうと、そちらに Focus が取られてしまいます。

PopupSelctButton で実装した MoveCursorToControl を、現在選択されている PopupSelectItem に対して実することで対処可能です。

PopupSelectMenu.cs
    // PopupSelectItem 選択状態変更
    public void SelectItem(int targetIndex)
    {
      // 選択状態変更
      int index = 0;
      foreach (PopupSelectItem item in this.Items)
      {
        item.IsChecked = (targetIndex == index++);
        if (item.IsChecked)
        {
          CommonOperations.MoveCursorToControl(item);  // マウスカーソル移動
          item.Focus();                                //フォーカス設定
        }
      }
    }
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?