使用MVVM生成菜单的DataTemplate

5
我想使用DataTemplate根据MVVM来从我的ViewModels创建菜单。基本上,我已经创建了几个类,将存储有关我的菜单结构的信息。然后,我想使用DataTemplate将该菜单结构实现为WPF菜单。
我有一个菜单服务,允许不同的组件注册新菜单和菜单项。以下是我的菜单信息(ViewModel)的组织方式:
我有以下类: MainMenuViewModel - 包含TopLevelMenuViewModelCollection(顶级菜单的集合)
TopLevelMenuViewModel - 包含MenuItemGroupViewModelCollection(菜单项组的集合)和菜单“文本”的名称
MenuItemGroupViewModel - 包含MenuItemViewModelCollection(菜单项的集合)
MenuItemViewModel - 包含文本、图像uri、命令和子项MenuItemViewModels
我想做的是将DataTemplate应用于之前的类,以将它们转换为普通菜单。
MainMenuViewModel -> Menu
TopLevelMenuViewModel -> 具有标题设置的菜单项
MenuItemGroupViewModel -> Separator,后跟每个MenuItemViewModel的MenuItem
MenuItemViewModel -> MenuItem(HeirarchicalDataTemplate)
问题在于我不知道如何为MenuItemGroupViewModel生成多个MenuItem。菜单模板希望始终为每个项目创建一个ItemContainer,而这个项目是一个MenuItem。因此,我要么最终使我的菜单项在一个MenuItem内,这显然是行不通的,要么就完全无法使用。我尝试了几种方法,仍然无法弄清如何使单个项目产生多个菜单项。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                xmlns:local="clr-namespace:--">
<!-- These data templates provide the views for the menu -->

<!-- MenuItemGroupView -->
<Style x:Key="MenuItemGroupStyle" TargetType="{x:Type MenuItem}">
    <Setter Property="Header" Value="qqq" />
    <!-- Now what? I don't want 1 item here..
    I wanted this to start with a <separator /> and list the MenuItemGroupViewModel.MenuItems -->
</Style>

<!-- TopLevelMenuView -->
<Style x:Key="TopLevelMenuStyle" TargetType="{x:Type MenuItem}">
    <Setter Property="Header" Value="{Binding Text}" />
    <Setter Property="ItemsSource" Value="{Binding MenuGroups}" />
    <Setter Property="ItemContainerStyle" Value="{StaticResource MenuItemGroupStyle}"/>
</Style>

<!-- MainMenuView -->
<DataTemplate DataType="{x:Type local:MainMenuViewModel}">
    <Menu ItemsSource="{Binding TopLevelMenus}" ItemContainerStyle="{StaticResource TopLevelMenuStyle}" />
</DataTemplate>

<!-- MenuItemView -->
<!--<HierarchicalDataTemplate DataType="{x:Type local:MenuItemViewModel}"
                              ItemsSource="{Binding Path=Children}"
                          >
    <HierarchicalDataTemplate.ItemContainerStyle>
        <Style TargetType="MenuItem">
            <Setter Property="Command"
                        Value="{Binding Command}" />
        </Style>
    </HierarchicalDataTemplate.ItemContainerStyle>
    <StackPanel Orientation="Horizontal">
        <Image Source="{Binding ImageSource}" />
        <TextBlock Text="{Binding Text}" />
    </StackPanel>
</HierarchicalDataTemplate>-->

请点击以下链接查看我尝试做的更好的图片

类图

我想要制作的基本菜单


太过混乱,没有图片就难以理解。 - Jake Berger
我添加了一些链接到清晰的图片,当你看到它时,它真的很简单。 - Alan
是否可以采用稍微不同的方式呢? 而不是使用“群组”,您是否可以创建一个占位符代替分隔符,就像这篇文章所示(http://www.codeproject.com/Articles/38440/WPF-If-Carlsberg-did-MVVM-Frameworks-Part-3-of-n#WPFMenuItems)。 - Jake Berger
问题在于WPF看到TopLevel.MenuGroups并说:“好的,让我们为每个创建一个菜单项。”当我第一次开始使用WPF和MVVM时,我也多次遇到“数据分离”的问题。但是,复杂性必须存在于某个地方,无论您是在转换器中使用数据还是其他方式。 - Jake Berger
我需要覆盖容器的模板或样式,以便不显示弹出窗口,我想知道是否可能。也许可以使用分隔符和组名来代替箭头和弹出窗口,在StackPanel中为子元素添加一些间距。 - Alan
显示剩余4条评论
2个回答

10
因为这有点复杂,我已经更新了这个答案,并提供了一个可下载的示例。 PrismMenuServiceExample 我的目标是允许不同的模块注册菜单命令,将它们分组并用标题排序菜单项。首先,让我们展示一下菜单的样子。

Grouped Menu Example

这很有用,例如,“工具”菜单可以有一个“Module1”组,其中列出了属于Module1的每个工具的菜单项,而Module1可以独立于其他模块注册。
我有一个“菜单服务”,允许模块注册新的菜单和菜单项。每个节点都有一个路径属性,告诉服务在哪里放置菜单。这个接口可能在基础设施项目中,以便所有模块都可以解决它。
public interface IMenuService
{
    void AddTopLevelMenu(MenuItemNode node);
    void RegisterMenu(MenuItemNode node);
}

我可以在适当的地方实现MenuService(基础设施项目,独立模块,甚至是Shell)。我会添加一些“默认”菜单,这些菜单在整个应用程序中定义,尽管任何模块都可以添加新的顶级菜单。
我本可以在代码中创建这些菜单,但我选择从资源中提取它们,因为在资源文件中以XAML形式编写它们更容易。我将该资源文件添加到我的应用程序资源中,但您也可以直接加载它。
public class MainMenuService : IMenuService
{
    MainMenuNode menu;
    MenuItemNode fileMenu;
    MenuItemNode toolMenu;
    MenuItemNode windowMenu;
    MenuItemNode helpMenu;

    public MainMenuService(MainMenuNode menu)
    {
        this.menu = menu;

        fileMenu = (MenuItemNode)Application.Current.Resources["FileMenu"];
        toolMenu = (MenuItemNode)Application.Current.Resources["ToolMenu"];
        windowMenu = (MenuItemNode)Application.Current.Resources["WindowMenu"];
        helpMenu = (MenuItemNode)Application.Current.Resources["HelpMenu"];

        menu.Menus.Add(fileMenu);
        menu.Menus.Add(toolMenu);
        menu.Menus.Add(windowMenu);
        menu.Menus.Add(helpMenu);
    }

    #region IMenuService Members

    public void AddTopLevelMenu(MenuItemNode node)
    {
        menu.Menus.Add(node);
    }

    public void RegisterMenu(MenuItemNode node)
    {
        String[] tokens = node.Path.Split('/');
        RegisterMenu(tokens.GetEnumerator(), menu.Menus, node);
    }

    #endregion

    private void RegisterMenu(IEnumerator tokenEnumerator, MenuItemNodeCollection current, MenuItemNode item)
    {
        if (!tokenEnumerator.MoveNext())
        {
            current.Add(item);
        }
        else
        {
            MenuItemNode menuPath = current.FirstOrDefault(x=> x.Text == tokenEnumerator.Current.ToString());

            if (menuPath == null)
            {
                menuPath = new MenuItemNode(String.Empty);
                menuPath.Text = tokenEnumerator.Current.ToString();
                current.Add(menuPath);
            }

            RegisterMenu(tokenEnumerator, menuPath.Children, item);
        }
    }
}

这是我的资源文件中一个预定义菜单的示例:
<!-- File Menu Groups -->
<menu:MenuGroupDescription x:Key="fileCommands"
                           Name="Files"
                           SortIndex="10" />
<menu:MenuGroupDescription x:Key="printerCommands"
                           Name="Printing"
                           SortIndex="90" />
<menu:MenuGroupDescription x:Key="applicationCommands"
                           Name="Application"
                           SortIndex="100" />

<menu:MenuItemNode x:Key="FileMenu"
                   x:Name="FileMenu"
                   Text="{x:Static inf:DefaultTopLevelMenuNames.File}"
                   SortIndex="10">
    <menu:MenuItemNode Group="{StaticResource fileCommands}"
                       Text="_Open File..."
                       SortIndex="10"
                       Command="{x:Static local:FileCommands.OpenFileCommand}" />
    <menu:MenuItemNode Group="{StaticResource fileCommands}" Text="Recent _Files" SortIndex="20"/>
    <menu:MenuItemNode Group="{StaticResource fileCommands}" Text="Con_vert..."  SortIndex="30"/>
    <menu:MenuItemNode Group="{StaticResource fileCommands}"
                       Text="_Export"
                       SortIndex="40"
                       Command="{x:Static local:FileCommands.ExportCommand}" />
    <menu:MenuItemNode Group="{StaticResource fileCommands}" Text="_Save" SortIndex="50"/>
    <menu:MenuItemNode Group="{StaticResource fileCommands}" Text="Save _All" SortIndex="60"/>
    <menu:MenuItemNode Group="{StaticResource fileCommands}"
                       Text="_Close"
                       SortIndex="70"
                       Command="{x:Static local:FileCommands.CloseCommand}" />
    <menu:MenuItemNode Group="{StaticResource printerCommands}" Text="Page _Setup..." SortIndex="10"/>
    <menu:MenuItemNode Group="{StaticResource printerCommands}" Text="_Print..." SortIndex="10"/>
    <menu:MenuItemNode Group="{StaticResource applicationCommands}"
                       Text="E_xit"
                       SortIndex="10"
                       Command="{x:Static local:FileCommands.ExitApplicationCommand}" />
</menu:MenuItemNode>

好的,这里列出了定义我的菜单系统结构的类型...(不是它的外观)

MainMenuNode基本上存在是为了方便您为其创建不同的模板。您可能想要一个菜单栏或表示整个菜单的东西。

public class MainMenuNode
{
    public MainMenuNode()
    {
        Menus = new MenuItemNodeCollection();
    }

    public MenuItemNodeCollection Menus { get; private set; }
}

以下是每个菜单项的定义。它们包括一个路径,告诉服务在哪里放置它们;一个排序索引,类似于TabIndex,可以按正确顺序进行组织;以及一个组描述,允许您将它们放入“组”中,这些组可以以不同的样式和排序方式进行排列。
[ContentProperty("Children")]
public class MenuItemNode : NotificationObject
{
    private string text;
    private ICommand command;
    private Uri imageSource;
    private int sortIndex;

    public MenuItemNode()
    {
        Children = new MenuItemNodeCollection();
        SortIndex = 50;
    }

    public MenuItemNode(String path)
    {
        Children = new MenuItemNodeCollection();
        SortIndex = 50;
        Path = path;
    }

    public MenuItemNodeCollection Children { get; private set; }

    public ICommand Command
    {
        get
        {
            return command;
        }
        set
        {
            if (command != value)
            {
                command = value;
                RaisePropertyChanged(() => this.Command);
            }
        }
    }

    public Uri ImageSource
    {
        get
        {
            return imageSource;
        }
        set
        {
            if (imageSource != value)
            {
                imageSource = value;
                RaisePropertyChanged(() => this.ImageSource);
            }
        }
    }

    public string Text
    {
        get
        {
            return text;
        }
        set
        {
            if (text != value)
            {
                text = value;
                RaisePropertyChanged(() => this.Text);
            }
        }
    }

    private MenuGroupDescription group;

    public MenuGroupDescription Group
    {
        get { return group; }
        set
        {
            if (group != value)
            {
                group = value;
                RaisePropertyChanged(() => this.Group);
            }
        }
    }

    public int SortIndex
    {
        get
        {
            return sortIndex;
        }
        set
        {
            if (sortIndex != value)
            {
                sortIndex = value;
                RaisePropertyChanged(() => this.SortIndex);
            }
        }
    }

    public string Path
    {
        get;
        private set;
    }

这是一个菜单项的集合:

public class MenuItemNodeCollection : ObservableCollection<MenuItemNode>
{
    public MenuItemNodeCollection() { }
    public MenuItemNodeCollection(IEnumerable<MenuItemNode> items) : base(items) { }
}

这是我如何对菜单项进行分组的方式。每个菜单项都有一个组描述。

public class MenuGroupDescription : NotificationObject, IComparable<MenuGroupDescription>, IComparable
{
    private int sortIndex;

    public int SortIndex
    {
        get { return sortIndex; }
        set
        {
            if (sortIndex != value)
            {
                sortIndex = value;
                RaisePropertyChanged(() => this.SortIndex);
            }
        }
    }

    private String name;

    public String Name
    {
        get { return name; }
        set
        {
            if (name != value)
            {
                name = value;
                RaisePropertyChanged(() => this.Name);
            }
        }
    }

    public MenuGroupDescription()
    {
        Name = String.Empty;
        SortIndex = 50;

    }

    public override string ToString()
    {
        return Name;
    }

    #region IComparable<MenuGroupDescription> Members

    public int CompareTo(MenuGroupDescription other)
    {
        return SortIndex.CompareTo(other.SortIndex);
    }

    #endregion

    #region IComparable Members

    public int CompareTo(object obj)
    {
        if(obj is MenuGroupDescription)
            return sortIndex.CompareTo((obj as MenuGroupDescription).SortIndex);
        return this.GetHashCode().CompareTo(obj.GetHashCode());
    }

    #endregion
}

我可以使用以下模板设计我的菜单外观:
<local:MenuCollectionViewConverter x:Key="GroupViewConverter" />

<!-- The style for the header of a group of menu items -->
<DataTemplate x:Key="GroupHeaderTemplate"
              x:Name="GroupHeader">
    <Grid x:Name="gridRoot"
          Background="#d9e4ec">
        <TextBlock Text="{Binding Name}"
                   Margin="4" />
        <Rectangle Stroke="{x:Static SystemColors.MenuBrush}"
                   VerticalAlignment="Top"
                   Height="1" />
        <Rectangle Stroke="#bbb"
                   VerticalAlignment="Bottom"
                   Height="1" />
    </Grid>
    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding Name}"
                     Value="{x:Null}">
            <Setter TargetName="gridRoot"
                    Property="Visibility"
                    Value="Collapsed" />
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>

<!-- Binds the MenuItemNode's properties to the generated MenuItem container -->
<Style x:Key="MenuItemStyle"
       TargetType="MenuItem">
    <Setter Property="Header"
            Value="{Binding Text}" />
    <Setter Property="Command"
            Value="{Binding Command}" />
    <Setter Property="GroupStyleSelector"
            Value="{x:Static local:MenuGroupStyleSelectorProxy.MenuGroupStyleSelector}" />
</Style>

<Style x:Key="TopMenuItemStyle"
       TargetType="MenuItem">
    <Setter Property="Header"
            Value="{Binding Text}" />
    <Setter Property="Command"
            Value="{Binding Command}" />
    <Setter Property="GroupStyleSelector"
            Value="{x:Static local:MenuGroupStyleSelectorProxy.MenuGroupStyleSelector}" />
    <Style.Triggers>
        <DataTrigger Binding="{Binding Path=Children.Count}"
                     Value="0">
            <Setter Property="Visibility"
                    Value="Collapsed" />
        </DataTrigger>
        <DataTrigger Binding="{Binding}"
                     Value="{x:Null}">
            <Setter Property="Visibility"
                    Value="Collapsed" />
        </DataTrigger>
    </Style.Triggers>
</Style>

<!-- MainMenuView -->
<DataTemplate DataType="{x:Type menu:MainMenuNode}">
    <Menu ItemsSource="{Binding Menus, Converter={StaticResource GroupViewConverter}}"
          ItemContainerStyle="{StaticResource TopMenuItemStyle}" />
</DataTemplate>

<!-- MenuItemView -->
<HierarchicalDataTemplate DataType="{x:Type menu:MenuItemNode}"
                          ItemsSource="{Binding Children, Converter={StaticResource GroupViewConverter}}"
                          ItemContainerStyle="{StaticResource MenuItemStyle}" />

这项工作的关键在于找出如何将正确的排序定义和分组定义注入到我的DataTemplate中的CollectionView中。我是这样做的:
[ValueConversion(typeof(MenuItemNodeCollection), typeof(IEnumerable))]
public class MenuCollectionViewConverter : IValueConverter
{

    #region IValueConverter Members

    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        if (targetType != typeof(IEnumerable))
            throw new NotImplementedException();

        CollectionViewSource src = new CollectionViewSource();
        src.GroupDescriptions.Add(new PropertyGroupDescription("Group"));
        src.SortDescriptions.Add(new SortDescription("Group", ListSortDirection.Ascending));
        src.SortDescriptions.Add(new SortDescription("SortIndex", ListSortDirection.Ascending));
        src.Source = value as IEnumerable;
        return src.View;
    }

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        if (value.GetType() != typeof(CollectionViewSource))
            throw new NotImplementedException();
        return (value as CollectionViewSource).Source;
    }

    #endregion
}

public static class MenuGroupStyleSelectorProxy
{
    public static GroupStyleSelector MenuGroupStyleSelector { get; private set; }

    private static GroupStyle Style { get; set; }

    static MenuGroupStyleSelectorProxy()
    {
        MenuGroupStyleSelector = new GroupStyleSelector(SelectGroupStyle);
        Style = new GroupStyle()
        {
            HeaderTemplate = (DataTemplate)Application.Current.Resources["GroupHeaderTemplate"]
        }; 
    }

    public static GroupStyle SelectGroupStyle(CollectionViewGroup grp, int target)
    {
        return Style;
    }
}

2
我认为你现在所面临的最大问题是对菜单项组的处理方式。所有菜单项都需要属于同一父级,因此不能使用像ItemsControl这样的东西来对它们进行处理。
相反,我会让每个TopLevelMenuItems暴露一个属性,即ObservableCollection<MenuItems>,它是一个只读集合,包含所有来自所有组的菜单项,具有由null值分隔的组,可用于标识分隔符。
例如:
public class TopLevelMenu
{
    public ObservableCollection<MenuItem> MenuItems
    {
        get
        {
            // Would be better to maintain a private collection for this instead of creating each time
            var collection = new ObservableCollection<MenuItem>();

            foreach(MenuGroup group in MenuGroups)
            {
                if (collection.Length > 0)
                    collection.Add(null); // Use null as separator placeholder

                foreach(MenuItem item in group.MenuItems)
                    collection.Add(item);
            }

            // Will return a collection containing all menu items in all groups, 
            // with the groups separated by a null value
            return collection; 
        }
    }
}

然后,您的数据模板可以将菜单绑定到扁平化集合,并使用触发器来识别哪些项目为null,并应该用分隔符绘制。

我可能语法有误,但以下是一个示例。默认模板应该是普通菜单项,使用DataTrigger来显示不同的模板,用于具有子对象或绑定到null对象的菜单项。

<Style TargetType="{x:Type MenuItem}">
    <Setter Property="Template" Value="{StaticResource DefaultMenuItemTemplate}" />
    <Style.Triggers>
        <DataTrigger Binding="{Binding }" Value="{x:Null}">
            <Setter Property="Template" Value="{StaticResource SeparatorTemplate}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding HasItems}" Value="True">
            <Setter Property="Template" Value="{StaticResource SubMenuItemTemplate}" />
        </DataTrigger>
    </Style.Triggers>
</Style>

当然,您可以使用实际对象来代替null值来标识您的Separators,但是我发现在我做过的其他项目中,nulls也可以很好地工作,因此我不认为我应该为自己创造更多的工作。

谢谢你的帮助。我明白你在说什么,我有点意识到菜单设置为扁平列表其他菜单项。我可以使我的数据扁平化,这样我就可以将HeirarchalDataTemplate绑定到MenuItemViewModel(我已经这样做了),但这与我所要实现的相反。我正在尝试在使用Prism V4的模块化应用程序中实现MVVM模式。基本上,每个模块加载时,它使用菜单服务注册一组属于该模块的MenuCommands,在默认顶级菜单下。 - Alan
因此,为了澄清,MenuService将向其他模块公开一个接口,允许它们创建一组菜单命令并将其添加到菜单中。然后,服务将创建MenuItemGroupViewModel对象,对其进行命名,并将其存储在TopLevelMenuViewModel中。这些“ViewModels”仅表示属于服务的数据,即具有可执行图片和名称的命令。它应该与用于创建用户可以使用以激活命令的GUI的DataTemplate无关。 - Alan
因此,修改我的ViewModel类以使DataTemplate工作并不符合MVVM设计实践。如果我无法通过DataTemplate解决问题,我想我会创建一个UserControl,向其添加菜单,并编写过程代码来添加项目,而不是使用DataTemplate。我只是真的想使用DataTemplate,因为它们应该更简单、更清洁,而且更不容易出错(?)。 - Alan
谢谢你的帮助,Rachel。我终于回复你了,告诉你我最终做了什么。你基本上是对的,我处理组的方式是错误的。使用该模板无法从单个组创建多个菜单项。我也会发布我的解决方案。 - Alan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接