1. 程式人生 > >[WPF 自定義控制元件]在MenuItem上使用RadioButton

[WPF 自定義控制元件]在MenuItem上使用RadioButton

1. 需求

上圖這種包含多選(CheckBox)和單選(RadioButton)的選單十分常見,可是在WPF中只提供了多選的MenuItem。順便一提,要使MenuItem可以多選,只需要將MenuItem的IsCheckable屬性設定為True:

<MenuItem IsCheckable="True"/>

不知出於何種考慮,WPF沒有為MenuItem提供單選的功能。為了在MenuItem中新增RadioButton,可以嘗試修改樣式並在CodeBehind找那個處理MenuItem的Click事件,但這種事做多了還是做成一個自定義控制元件比較方便。這篇文章將介紹如何自定義一個RadioButtonMenuItem

控制元件實現MenuItem的單選功能。

2. 實現程式碼

RadioButtonMenuItem的程式碼比較簡單(換言之,樣式部分比較難),首先繼承自MenuItem,然後模仿RadioButton新增一個GroupName屬性:

public class RadioButtonMenuItem : MenuItem
{
    /// <summary>
    /// 標識 GroupName 依賴屬性。
    /// </summary>
    public static readonly DependencyProperty GroupNameProperty =
        DependencyProperty.Register(nameof(GroupName), typeof(string), typeof(RadioButtonMenuItem), new PropertyMetadata(default(string)));

    static RadioButtonMenuItem()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(RadioButtonMenuItem), new FrameworkPropertyMetadata(typeof(RadioButtonMenuItem)));
    }

    /// <summary>
    /// 獲取或設定GroupName的值
    /// </summary>
    public string GroupName
    {
        get { return (string)GetValue(GroupNameProperty); }
        set { SetValue(GroupNameProperty, value); }
    }

RadioButtonMenuItem的分組規則很簡單,只要同一個MenuItem下的RadioButtonMenuItem為一組,然後再根據GroupName分組。因為我很少會更改GroupName,所以就難得監視GroupName的改變了。

因為MenuItem派生自ItemsControl,所以需要重寫GetContainerForItemOverride以確定它的Items也是用RadioButtonMenuItem作為預設的ItemContainer:

protected override DependencyObject GetContainerForItemOverride()
{
    return new RadioButtonMenuItem();
}

然後重寫OnClick,讓RadioButtonMenuItem每次點選都被選中,這個行為和RadioButton一致:

protected override void OnClick()
{
    base.OnClick();
    IsChecked = true;
}

最後重寫OnClick函式,在這個函式裡面找出在同一個MenuItem下且GroupName一樣的RadioButtonMenuItem,將他們的IsChecked全部設定為False,這樣就實現了MenuItem的單選功能:

protected override void OnChecked(RoutedEventArgs e)
{
    base.OnChecked(e);

    if (this.Parent is MenuItem parent)
    {
        foreach (var menuItem in parent.Items.OfType<RadioButtonMenuItem>())
        {
            if (menuItem != this && menuItem.GroupName == GroupName && (menuItem.DataContext == parent.DataContext || menuItem.DataContext != DataContext))
            {
                menuItem.IsChecked = false;
            }
        }
    }
}

3. 實現樣式

MenuItem有一個Role屬性,它的型別為MenuItemRole,定義如下:

//
// 摘要:
//     Defines the different roles that a System.Windows.Controls.MenuItem can have.
public enum MenuItemRole
{
    //
    // 摘要:
    //     Top-level menu item that can invoke commands.
    TopLevelItem = 0,
    //
    // 摘要:
    //     Header for top-level menus.
    TopLevelHeader = 1,
    //
    // 摘要:
    //     Menu item in a submenu that can invoke commands.
    SubmenuItem = 2,
    //
    // 摘要:
    //     Header for a submenu.
    SubmenuHeader = 3
}

根據MenuItem所處的位置,它的Role會有不同的值,大致上如下面例子所示:

<Menu x:Name="Men">
    <MenuItem Header="TopLevelItem" />
    <MenuItem Header="TopLevelHeader">
        <MenuItem Header="SubMenuHeader">
            <MenuItem Header="SubMenuItem" />
        </MenuItem>
        <MenuItem Header="SubMenuItem" />
    </MenuItem>
</Menu>

MenuItem的樣式麻煩之處就在這裡。因為微軟並沒有在文件中提供Aero2的樣式,所以在以前要獲取一個控制元件的樣式標準的做法是使用Blend選中控制元件後編輯控制元件的模板,但因為MenuItem會有不同的Role,所以它當前的模板會不一樣,用Blend很難獲取到它的全部的模板。大致上它的樣式定義如下:

<ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelItemTemplateKey}"
                 TargetType="{x:Type MenuItem}">
</ControlTemplate>
<ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelHeaderTemplateKey}"
                 TargetType="{x:Type MenuItem}">
  
</ControlTemplate>

<ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuItemTemplateKey}"
                 TargetType="{x:Type MenuItem}">
</ControlTemplate>

<ControlTemplate x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuHeaderTemplateKey}"
                 TargetType="{x:Type MenuItem}">
</ControlTemplate>

<Style x:Key="{x:Type local:RadioButtonMenuItem}"
       TargetType="{x:Type local:RadioButtonMenuItem}">
    <Setter Property="Control.Template"
            Value="{StaticResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuItemTemplateKey}}" />
    <Style.Triggers>
        <Trigger Property="MenuItem.Role"
                 Value="TopLevelHeader">
            <Setter Property="Control.Template"
                    Value="{StaticResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelHeaderTemplateKey}}" />
            <Setter Property="Control.Padding"
                    Value="6,0" />
        </Trigger>
        <Trigger Property="MenuItem.Role"
                 Value="TopLevelItem">
            <Setter Property="Control.Template"
                    Value="{StaticResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=TopLevelItemTemplateKey}}" />
            <Setter Property="Control.Padding"
                    Value="6,0" />
        </Trigger>
        <Trigger Property="MenuItem.Role"
                 Value="SubmenuHeader">
            <Setter Property="Control.Template"
                    Value="{StaticResource {ComponentResourceKey TypeInTargetAssembly={x:Type MenuItem}, ResourceId=SubmenuHeaderTemplateKey}}" />
        </Trigger>
    </Style.Triggers>
</Style>

除了使用Blend,以前還可以使用ILSpy反編譯出它的資原始檔獲取控制元件的樣式。幸好現在WPF開元了,Aero2的樣式也可以在 Github 上找到。大概500行的樣子,雖然大致上只需要將CheckBox的換成一個圓點,但分別搞四次加上些細微的調整把我搞糊塗了。因為它只提供了Aero2的樣式,如果要用在Win7最好再定義一個Aero的樣式,或者直接將全域性樣式改為Aero2,我在 這篇文章 裡介紹瞭如何在Win7使用Aero2的樣式,可供參考。

修改完模板後效果就如文章開頭的圖片一樣了,使用方法如下:

<kino:RadioButtonMenuItem Header="MoreOptions">
    <kino:RadioButtonMenuItem Header="Option 1"
                                  GroupName="GroupA" />
    <kino:RadioButtonMenuItem Header="Option 2"
                                  GroupName="GroupA" />
    <kino:RadioButtonMenuItem Header="Option 3"
                                  GroupName="GroupA" />
    <Separator />
    <kino:RadioButtonMenuItem Header="Option 4"
                                  GroupName="GroupB" />
    <kino:RadioButtonMenuItem Header="Option 5"
                                  GroupName="GroupB" />
    <kino:RadioButtonMenuItem Header="Option 6"
                                  GroupName="GroupB" />
    
    
    <Separator />
    <kino:RadioButtonMenuItem Header="Options ">
        <kino:RadioButtonMenuItem Header="Option 7"
                                      GroupName="GroupC" />
        <kino:RadioButtonMenuItem Header="Option 8"
                                      GroupName="GroupC" />
        <kino:RadioButtonMenuItem Header="Option 9"
                                      GroupName="GroupC" />
    </kino:RadioButtonMenuItem>
    <Separator />
    <MenuItem IsCheckable="True"
              Header="Option X" />
    <MenuItem IsCheckable="True"
              Header="Option Y" />
    <MenuItem IsCheckable="True"
              Header="Option Z" />
</kino:RadioButtonMenuItem>

4. 參考

MenuItem Class (System.Windows.Controls) _ Microsoft Docs

MenuItemRole Enum (System.Windows.Controls) _ Microsoft Docs

RadioButton Class (System.Windows.Controls) _ Microsoft Docs

» WPF MenuItem as a RadioButton WPF

wpf_MenuItem.xaml at master · dotnet_wpf

5. 原始碼

RadioButtonMenuItem.cs at master