1
votes

What I'm trying to accomplish

I'm trying to have my nested menu item change the shown user control. In more technical terms, I'm trying to:

  1. Get a click event attached to a nested MenuItem (from my MyMenu.cs file - implements INotifyPropertyChanged), to...
  2. Use RoutedEventHandler (maybe from the MyMenu.cs file? - implements UserControl), to...
  3. Call the SwitchScreen method (from my MainWindow.cs file - implements Window)

Where I'm getting stuck

I can't seem to find a way to add the click event to the appropriate menu item.
My current logic also requires the original sender to be passed as an argument so that I can identify the correct MySubview to display.

XAML Handler

I've tried adding the click event in xaml as follows, but it only adds the handler to the first menu item level (not to nested menu item elements).

<MenuItem ItemsSource="{Binding Reports, Mode=OneWay}" Header="Reports">
    <MenuItem.ItemContainerStyle>
        <Style TargetType="{x:Type MenuItem}">
            <EventSetter Event="Click" Handler="MenuItem_Click"/>
        </Style>
    </MenuItem.ItemContainerStyle>
</MenuItem>

C# Setter

I've tried adding a setter, suggested in this answer, but I can't seem to create a click event from MyMenu.cs to MyMenuUserControl.cs.

Style style = new Style();
style.BasedOn = menuItem2.Style;

style.Setters.Add(new EventSetter( /* ??? */ ));

C# ICommand

I've tried using ICommand, suggested in this answer, but I can't seem to create a relay command from MyMenu.cs to MyMenuUserControl.cs.

I may be doing something wrong in one of these attempts, but I'm now past the point of playing around and ready to throw in the towel.


Notes

Actual structure

In reality, my actual code has n-nested foreach loops to generate the menu and I remove a level of nesting if a the foreach enumerable (e.g. myObjects) only has one element.
The removal of a level of nesting also moves the click event up one level.
My final menu could look something like this:

My menu items:

  • Item (menuItem1)
    • Item (menuItem2)
      • Item (menuItem3) + click event
      • Item (menuItem3) + click event
    • Item (menuItem2) + click event (see A)
  • Item (menuItem1) + click event (see B)

A: Only one menuItem3 is nested, so we remove it (it's redundant) and we move the click event up to menuItem2.

B: Only one menuItem2 is nested, and it only has one menuItem3. Both are removed as they're redundant and we move the click event is moved to menuItem1.

This is why I'd like to maintain the creation of the menu items in the MyMenu class.

Other suggestions

I could be going about this completely wrong and I'm open to suggestions that change the way I'm going about this.


Code

MyMenu.cs

The constructor in this class generates my menu items and its sub-menu items.
This is where I'm trying to add a click event.

class MyMenu : INotifyPropertyChanged
{
    private List<MenuItem> menuItems = new List<MenuItem>();
    public List<MenuItem> MenuItems
    {
         get { return menuItem; }
         set
         {
             menuItem = value;
             OnPropertyChanged();
         }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    public List<Tuple<MyObject, MenuItem>> Map { get; private set; } = new List<Tuple<MyObject, MenuItem>>();

    public MyMenu(List<MyObject> myObjects)
    {
         foreach(MyObject myObject in myObjects)
         {
             MenuItem menuItem1 = new MenuItem { Header = myObject.Name };

             foreach(string s in myObject.Items)
             {
                 MenuItem menuItem2 = new MenuItem { Header = s };

                 // Add click event to menuItem2 here

                 menuItem1.Items.Add(menuItem2);
                 Map.Add(new Tuple<MyObject, MenuItem>(myObject, menuItem2));
             }
             MenuItem.Add(menuItem1);
         }
    }

    private void OnPropertyChanged([CallerMemberName] string name = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

MyMenuUserControl.xaml

Minimal code sample UserControl (uses default xmlns attributes).
MyMenuUserControl.xaml.cs only has constructor with InitializeComponent();

<UserControl>
    <!-- xmlns default attributes in UserControl above removed for minimal code -->
    <Menu>
        <Menu.ItemsPanel>
            <ItemsPanelTemplate>
                <DockPanel VerticalAlignment="Stretch"/>
            </ItemsPanelTemplate>
        </Menu.ItemsPanel>
        <MenuItem ItemsSource="{Binding MenuItems, Mode=OneWay}" Header="My menu items"/>
    </Menu>
</UserControl>

MyDataContext.cs

Minimal code sample (same PropertyChangedEventHandler and OnPropertyChanged() code as MyMenu.cs). Constructor simply sets Menu and Subviews properties.

class MyDataContext : INotifyPropertyChanged { private MyMenu menu; public MyMenu Menu { get { return menu; } set { menu = value; OnPropertyChanged(); } }

private List<MySubview> mySubviews;
public List<MySubview> MySubviews
{
    get { return mySubviews; }
    set
    {
        mySubviews = value;
        OnPropertyChanged();
    }
}
// ... rest of code removed to maintain minimal code

}

MainWindow.xaml.cs

Subview contains a property of MyObject type.
This allows me to use MyMenu's Map property to identify which Subview to display for a given MenuItem's click.
Yes, making the map at the MainWindow map might be easier, however the logic I have in MyMenu is a minimal example (see Notes for more info).

public partial class MainWindow : Window { public MainWindow() { InitializeComponent();

    // I get my data here
    List<MyObject> myObjects = ...
    List<MySubview> mySubviews = ...

    DataContext = new MyDataContext(new MyMenu(myObjects), new MySubviews(mySubviews));
}

private void SwitchScreen(object sender, RoutedEventArgs e)
{
    MyDataContext c = (MyDataContext)DataContext;
    MyObject myObject = c.MyMenu.Map.Where(x => x.Item2.Equals(sender as MenuItem)).Select(x => x.Item1).First();
    MySubview shownSubview = c.MySubviews.Where(x => x.MyObject.Equals(myObject)).First();

    c.MySubviews.ForEach(x => x.Visibility = Visibility.Collapsed);
    shownSubview.Visibility = Visibility.Visible;
}

}

2

2 Answers

2
votes

Wpf is designed to be used via the MVVM pattern. You appear to be trying to manipulate the visual tree directly, which is probably where a lot of your problems are coming from since you appear to be half way between worlds.

What is MyMenu.cs? It looks like a view model but it contains visual items (MenuItem). VMs should not contain any visual classes. They are a data abstraction of the view.

It looks like your MyMenuVM.cs should just expose your List <MyObject>, and your view menu should bind to that. MenuItem already has a built in ICommand (after all menus are made for clicking), so you don't need to add your own click handlers. Instead you bind MenuItem.Command to a command in your VM, and possibly bind CommandParameter to supply which MyObject is firing the command.

In short, I would read up a bit about MVVM because it will make your code far cleaner and easier to understand and hopefully prevent these kind of issues.

1
votes

Menu can build its items from ItemsSource using any IEnumerable object. One thing you should do - set a DataTemplate for mapping MenuItem's properties to your VM properties.

I collected some links for you that may be useful in understanding how it may be done with MVVM:

  • RelayCommand class - refer to Relaying Command Logic section. From my (WPF newbie's) perspective it's the best way of using Commands.
  • HierarchicalDataTemplate - same as DataTemplate but with ItemsSource.
  • Trick with Separators - may help with making Menu containing not only MenuItems in its ItemsSource (tested!)
  • ObservableCollection - use it instead of List for UI purpose. It fires CollectionChanged event inside when you dinamically add or remove items. And Control with ItemsSource updates its layout immediately, out-of-the-box.

Why not just a collection of Controls?

Because you may break you application causing an Exception while trying to interact with UI Elements from different Thread. Yes, you may use Dispatcher.Invoke for fix but there's a better way avoiding it: simply use Binding. Thus, you may forget about Dispatcher.Invoke-everywhere problem.

Simple Example

Using single RelayCommand for all MenuItem instances.

MainWindow.xaml

<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApp1"
        Title="MainWindow" Height="300" Width="400">
    <Window.DataContext>
        <local:MainViewModel/>
    </Window.DataContext>
    <Window.Resources>
        <local:MenuItemContainerTemplateSelector x:Key="MenuItemContainerTemplateSelector"/>
        <Style x:Key="SeparatorStyle" TargetType="{x:Type Separator}" BasedOn="{StaticResource ResourceKey={x:Static MenuItem.SeparatorStyleKey}}"/>
        <Style x:Key="MenuItemStyle" TargetType="{x:Type MenuItem}">
            <Setter Property="Header" Value="{Binding Header}"/>
            <Setter Property="Command" Value="{Binding DataContext.MenuCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
            <Setter Property="CommandParameter" Value="{Binding CommandName}"/>
        </Style>
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="20"/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Menu Grid.Row="0" >
            <MenuItem Header="Menu" ItemsSource="{Binding MenuItems}" UsesItemContainerTemplate="True" ItemContainerTemplateSelector="{StaticResource MenuItemContainerTemplateSelector}">
                <MenuItem.Resources>
                    <HierarchicalDataTemplate DataType="{x:Type local:MyMenuItem}" ItemsSource="{Binding Items}" >
                        <MenuItem Style="{StaticResource MenuItemStyle}" UsesItemContainerTemplate="True" ItemContainerTemplateSelector="{StaticResource MenuItemContainerTemplateSelector}"/>
                    </HierarchicalDataTemplate>
                    <DataTemplate DataType="{x:Type local:MySeparator}">
                        <Separator Style="{StaticResource SeparatorStyle}"/>
                    </DataTemplate>
                </MenuItem.Resources>
            </MenuItem>
        </Menu>
    </Grid>
</Window>

RelayCommand.cs

public class RelayCommand : ICommand
{
    private readonly Action<object> _execute;
    private readonly Func<object, bool> _canExecute;

    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }

    public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter) => _canExecute == null || _canExecute(parameter);
    public void Execute(object parameter) => _execute(parameter);
}

MenuItemContainerTemplateSelector.cs

public class MenuItemContainerTemplateSelector : ItemContainerTemplateSelector
{
    public override DataTemplate SelectTemplate(object item, ItemsControl parentItemsControl) =>
        (DataTemplate)parentItemsControl.FindResource(new DataTemplateKey(item.GetType()));
}

MenuItemViewModel.cs

public interface IMyMenuItem
{
}
public class MySeparator : IMyMenuItem
{
}
public class MyMenuItem : IMyMenuItem, INotifyPropertyChanged
{
    private string _commandName;
    private string _header;
    private ObservableCollection<IMyMenuItem> _items;
    public string Header
    {
        get => _header;
        set
        {
            _header = value;
            OnPropertyChanged();
        }
    }
    public string CommandName
    {
        get => _commandName;
        set
        {
            _commandName = value;
            OnPropertyChanged();
        }
    }
    public ObservableCollection<IMyMenuItem> Items
    {
        get => _items ?? (_items = new ObservableCollection<IMyMenuItem>());
        set
        {
            _items = value;
            OnPropertyChanged();
        }
    }
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

MainViewModel.cs

public class MainViewModel : INotifyPropertyChanged
{
    private ObservableCollection<IMyMenuItem> _menuItems;
    private ICommand _menuCommand;

    public ObservableCollection<IMyMenuItem> MenuItems
    {
        get => _menuItems ?? (_menuItems = new ObservableCollection<IMyMenuItem>());
        set
        {
            _menuItems = value;
            OnPropertyChanged();
        }
    }

    public ICommand MenuCommand => _menuCommand ?? (_menuCommand = new RelayCommand(param =>
    {
        if (param is string commandName)
        {
            switch (commandName)
            {
                case "Exit":
                    Application.Current.MainWindow.Close();
                    break;
                default:
                    MessageBox.Show("Command name: " + commandName, "Command executed!");
                    break;
            }
        }
    }, param =>
    {
        return true; // try return here false and check what will happen
    }));
    public MainViewModel()
    {
        MenuItems.Add(new MyMenuItem() { Header = "MenuItem1", CommandName = "Command1" });
        MenuItems.Add(new MyMenuItem() { Header = "MenuItem2", CommandName = "Command2" });
        MyMenuItem m = new MyMenuItem() { Header = "MenuItem3" };
        MenuItems.Add(m);
        m.Items.Add(new MyMenuItem() { Header = "SubMenuItem1", CommandName = "SubCommand1" });
        m.Items.Add(new MySeparator());
        m.Items.Add(new MyMenuItem() { Header = "SubMenuItem2", CommandName = "SubCommand2" });
        m.Items.Add(new MyMenuItem() { Header = "SubMenuItem3", CommandName = "SubCommand3" });
        MenuItems.Add(new MySeparator());
        MenuItems.Add(new MyMenuItem() { Header = "Exit", CommandName = "Exit" });
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

Screenshot