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

2022-10-09,,,

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

《[WPF 自定义控件]在MenuItem上使用RadioButton.doc》

下载本文的Word格式文档,以方便收藏与打印。