Search code examples
wpfwpf-controlsprism-4

DataTemplate to generate Menu with MVVM


I'm trying to use a DataTemplate to create a Menu from my ViewModels with respect to MVVM. Basically, I've created several classes which will store information about my Menu structure. I then want to realize that menu stucture as a WPF Menu using a DataTemplate.

I have a menu service which allows different components to register new menus and items within the menus. Here's how I've organized my menu information (ViewModel)

I have the following classes: MainMenuViewModel - Contains a TopLevelMenuViewModelCollection (a collection of top level menus)

TopLevelMenuViewModel - Contains a MenuItemGroupViewModelCollection (a collection of groups of menu items), and a name for the menu 'Text'

MenuItemGroupViewModel - Contains a MenuItemViewModelCollection (collection of menu items)

MenuItemViewModel - Contains text, image uri, command, children MenuItemViewModels

What I want to do is apply a DataTemplate to the previous classes to transform them into a normal Menu.

MainMenuViewModel -> Menu

TopLevelMenuViewModel -> MenuItems with header set

MenuItemGroupViewModel -> Separator followed by a MenuItem for each MenuItemViewModel

MenuItemViewModel -> MenuItem (HeirarchicalDataTemplate)

The problem is I don't see how to generate multiple MenuItems for the MenuItemGroupViewModel. The Menu template wants to always create an ItemContainer for each item which is a MenuItem. Therefore, I either end up with my MenuItems inside a MenuItem which obviously doesn't work, or it doesn't work at all. I've tried several things and still cannot figure out how to make a single item produce more than one 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>-->

Please Click the links to see a better picture of what I'm trying to do

Class Diagram

Basic Menu I want to Make


Solution

  • Because this is sort of complicated, I've updated this answer with a downloadable example.

    PrismMenuServiceExample

    My goal was to allow different modules to register menu commands and group them together with a title and sort the menu items in a proper order. First of all, let's show an example of what the menu looks like.

    Grouped Menu Example

    This is useful, for example a "Tools" menu could have a "Module1" group that has menu items listed for each tool that belongs to Module1 which Module1 can register independently of the other modules.

    I have a "menu service" which allows modules to register new menus and menu items. Each node has a Path property which informs the service where to place the menu. This interface is likely in the infrastructure project, so that all modules can resolve it.

    public interface IMenuService
    {
        void AddTopLevelMenu(MenuItemNode node);
        void RegisterMenu(MenuItemNode node);
    }
    

    I can then implement that MenuService wherever is appropriate. (Infrastructure project, Separate Module, maybe the Shell). I go ahead and add some "default" menus that are defined application wide, although any module can add new top level menus.

    I could have created these menus in code, but I instead pulled them out of the resources because it was easier to write them out in XAML in a resource file. I'm adding that resource file to my application resources, but you could load it directly.

    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);
            }
        }
    }
    

    Here's an example of one of those pre-defined menus in my resource file:

    <!-- 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>
    

    OK, here lists the types that define the structure of my menu system... (Not what it looks like)

    The MainMenuNode basically exists so that you can easily create a different template for it. You probably what a menu bar or something that represents the menu as a whole.

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

    Here's the definition for each MenuItem. They include a Path which tells the service where to put them, a SortIndex which is sort of like TabIndex that allows them to be organized in the proper order, and a GroupDescription which allows you to put them into "groups" which can be styled differently and sorted.

    [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;
        }
    

    And a collection of menu items:

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

    Here's how I ended up grouping MenuItems.. Each one has a GroupDescription

    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
    }
    

    I then can design what my menu looks like with the following templates:

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

    A key to make this work was figuring out how to inject my CollectionView with proper sorting definitions and grouping definitions into my DataTemplate. This is how I did it:

    [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;
        }
    }