[WPF 自定义控件]在MenuItem上使用RadioButton

本文介绍如何在WPF中自定义RadioButtonMenuItem控件,实现类似RadioButton的单选功能,通过添加GroupName属性和重写相关方法,配合样式调整,达到在不同层级的菜单中实现单选的效果。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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会有不同的值,大致上如下面例子所示:

下图中Header 的值对应样式<Trigger> 执行的内容块,层层递进,挪用不同的样式

<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}">

<Border x:Name="templateRoot"
                BorderBrush="{TemplateBinding BorderBrush}"
                BorderThickness="{TemplateBinding BorderThickness}"
                Background="{TemplateBinding Background}"
                SnapsToDevicePixels="True">
            <Grid VerticalAlignment="Center">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="Auto" />
                </Grid.ColumnDefinitions>
                <ContentPresenter x:Name="Icon"
                                  Content="{TemplateBinding Icon}"
                                  ContentSource="Icon"
                                  HorizontalAlignment="Center"
                                  Height="16"
                                  Margin="3"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                  VerticalAlignment="Center"
                                  Width="16" />
<!--此处根据使用者需要替换成相应icon-->
                <Ellipse x:Name="GlyphPanel"
                         Visibility="Collapsed"
                         Fill="#FF212121"
                         Width="10"
                         Stretch="Uniform"
                         Height="6"
                         Margin="3" />
                <ContentPresenter ContentTemplate="{TemplateBinding HeaderTemplate}"
                                  Content="{TemplateBinding Header}"
                                  Grid.Column="1"
                                  ContentStringFormat="{TemplateBinding HeaderStringFormat}"
                                  ContentSource="Header"
                                  Margin="{TemplateBinding Padding}"
                                  RecognizesAccessKey="True"
                                  SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
            </Grid>
        </Border>
        <ControlTemplate.Triggers>
            <Trigger Property="Icon"
                     Value="{x:Null}">
                <Setter Property="Visibility"
                        TargetName="Icon"
                        Value="Collapsed" />
            </Trigger>
            <Trigger Property="IsChecked"
                     Value="True">
                <Setter Property="Visibility"
                        TargetName="GlyphPanel"
                        Value="Visible" />
                <Setter Property="Visibility"
                        TargetName="Icon"
                        Value="Collapsed" />
            </Trigger>
            <Trigger Property="IsHighlighted"
                     Value="True">
                <Setter Property="Background"
                        TargetName="templateRoot"
                        Value="#3D26A0DA" />
                <Setter Property="BorderBrush"
                        TargetName="templateRoot"
                        Value="#FF26A0DA" />
            </Trigger>
            <Trigger Property="IsEnabled"
                     Value="False">
                <Setter Property="TextElement.Foreground"
                        TargetName="templateRoot"
                        Value="#FF707070" />
                <Setter Property="Fill"
                        TargetName="GlyphPanel"
                        Value="#FF707070" />
            </Trigger>
            <MultiTrigger>
                <MultiTrigger.Conditions>
                    <Condition Property="IsHighlighted"
                               Value="True" />
                    <Condition Property="IsEnabled"
                               Value="False" />
                </MultiTrigger.Conditions>
                <Setter Property="Background"
                        TargetName="templateRoot"
                        Value="#0A000000" />
                <Setter Property="BorderBrush"
                        TargetName="templateRoot"
                        Value="#21000000" />
            </MultiTrigger>
        </ControlTemplate.Triggers>
</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>

 

### RadioButton 控件使用方法 在 Windows Presentation Foundation (WPF) 中,`RadioButton` 是一种常见的用于表示互斥选项的选择控件。然而,在 `MenuItem` 上直接应用单选按钮并非 WPF 的默认行为[^1]。 #### 创建自定义的 `RadioButtonMenuItem` 由于 WPF 默认不支持 `MenuItem` 单选功能,可以通过创建一个名为 `RadioButtonMenuItem` 的自定义控件来实现这一需求。该过程涉及修改现有样式的模板以及编写逻辑以响应点击事件并管理组内的状态切换。 ```csharp public class RadioButtonMenuItem : MenuItem { static RadioButtonMenuItem() { DefaultStyleKeyProperty.OverrideMetadata(typeof(RadioButtonMenuItem), new FrameworkPropertyMetadata(typeof(RadioButtonMenuItem))); } public bool IsChecked { get { return (bool)GetValue(IsCheckedProperty); } set { SetValue(IsCheckedProperty, value); } } // Using a DependencyProperty as the backing store for IsChecked. This enables animation, styling, binding, etc... public static readonly DependencyProperty IsCheckedProperty = DependencyProperty.Register("IsChecked", typeof(bool), typeof(RadioButtonMenuItem), new PropertyMetadata(false)); } ``` 此代码片段展示了如何通过继承 `MenuItem` 来扩展其功能,并引入了一个新的依赖属性 `IsChecked` 以便追踪当前项是否被选中。 #### 处理 Click 事件 当用户选择菜单中的某一项时,需要触发相应的命令或操作。这通常涉及到订阅 `Click` 事件处理器: ```csharp private void OnMenuItemClicked(object sender, RoutedEventArgs e){ var item = sender as RadioButtonMenuItem; if(item != null && !item.IsChecked){ foreach(var sibling in GetSiblings()){ ((RadioButtonMenuItem)sibling).IsChecked = false; } item.IsChecked = true; } } // Helper method to find siblings within same group private IEnumerable<RadioButtonMenuItem> GetSiblings(){ DependencyObject parent = VisualTreeHelper.GetParent(this); while(parent != null && !(parent is ItemsControl)){ parent = VisualTreeHelper.GetParent(parent); } if(parent is ItemsControl itemsControl){ foreach(var child in LogicalTreeHelper.GetChildren(itemsControl)){ if(child is RadioButtonMenuItem rbItem && rbItem != this){ yield return rbItem; } } } } ``` 上述 C# 方法实现了对同级节点遍历的能力,确保每次只有一个项目处于选定状态。 ### 常见问题解决方案 - **无法正常显示图标**: 如果遇到图像资源加载失败的情况,请确认路径设置无误,并检查应用程序配置文件中是否有正确的 URI 映射。 - **绑定数据源更新延迟**: 当发现界面刷新滞后于实际的数据变化时,建议核查数据上下文(DataContext) 是否已正确定义,同时验证通知机制(INotifyPropertyChanged 接口) 已经妥善实施。 - **跨平台兼容性挑战**: 对于希望在同一套代码基础上运行不同操作系统上的开发者来说,需注意各平台上 UI 组件的具体表现差异,特别是像 Android 和 iOS 这样的移动环境下的特殊要求[^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值