1

I have a menu, composed by radio buttons, used to navigate between pages. The first page is loaded when the app is opened.

The navigation between the pages is done this way:

<Window.Resources>
        <DataTemplate DataType="{x:Type FirstViewModel}">
            <FirstView />
        </DataTemplate>
        <DataTemplate DataType="{x:Type SecondViewModel}">
            <SecondView />
        </DataTemplate>
</Window.Resources>

So the DataContext is updated everytime a new page is selected.

Following this approach: https://stackoverflow.com/a/61323201/17198402

MainView.xaml:

<Border Grid.Column="0">
            <Grid Background="AliceBlue">
                <Border
                    Width="10"
                    HorizontalAlignment="Left"
                    Background="SlateGray" />
                <ItemsControl>
                    <StackPanel Orientation="Vertical">
                        <RadioButton
                            Command="{Binding ShowPageCommand}"
                            CommandParameter=//not important
                            IsChecked="{Binding IsActive, UpdateSourceTrigger=PropertyChanged}"
                            Style="{StaticResource RadioButtonStyle}" 
                            Content="First"/>
                        <RadioButton
                            Command="{Binding ShowPageCommand}"
                            CommandParameter=//not important
                            IsChecked="{Binding IsActive, UpdateSourceTrigger=PropertyChanged}"
                            Style="{StaticResource RadioButtonStyle}" 
                            Content="Second"/>
                    </StackPanel>
                 </ItemsControl>
             </Grid>
</Border>

RadioButtonStyle:

<Style x:Key="RadioButtonStyle" TargetType="RadioButton">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type RadioButton}">
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="Auto" />
                                <ColumnDefinition Width="*" />
                            </Grid.ColumnDefinitions>
                            <Border Grid.Column="0" Width="10">
                                <Border.Style>
                                    <Style TargetType="Border">
                                        <Setter Property="Background" Value="Transparent" />
                                        <Style.Triggers> //MOST IMPORTANT!!
                                            <DataTrigger Binding="{Binding Path=IsChecked, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ToggleButton}}}" Value="True">
                                                <Setter Property="Background" Value="#D50005" />
                                            </DataTrigger>
                                        </Style.Triggers>
                                    </Style>
                                </Border.Style>
                            </Border>
                            <Border
                                Grid.Column="1"
                                Background="{TemplateBinding Background}"
                                BorderBrush="{TemplateBinding BorderBrush}">
                                <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
                            </Border>
                        </Grid>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
</Style>

Basically, when a radio button is clicked, the page it is binded to is loaded and the border near the button turns red - that's how it indicates that the page is opened.

IsActive is a property inside every page's ViewModel.

Everything works perfectly, however, when I open the application I want the first radio button to be already selected and the border near it to be red. When I navigate to another page, everything works as expected.

What I have tried:

  1. Giving the first radio button a name, e.g. FirstRadioButton, and calling FirstRadioButton.IsChecked = true in MainView.xaml.cs. Nothing is triggered.

  2. In the MainViewModel.cs:

public MainViewModel(FirstViewModel firstViewModel, SecondViewModel secondViewModel)
{
    firstViewModel.IsActive = true;

    Pages = new Dictionary<PageName, IPage>
    {
         { PageName.FirstView, firstViewModel },
         { PageName.SecondView, secondViewModel }
    };

    //other code..
}

public enum PageName
{
    Undefined = 0,
    FirstView = 1,
    SecondView = 2
}

This PageName thing is part of the navigation and I am also injecting the ViewModels using dependency injection.

What is the correct approach to this?

Melissa
  • 135
  • 1
  • 7
  • You wrote "calling `FirstRadioButton.IsChecked = true` in `MainView.xaml.cs`. Can you specify in which method (ctor, `OnInitialized()`, `OnRender()`, ...)? Also, is it normal that *both* your radiobuttons have their `IsChecked` property bound to the same `IsActive` property, without a converter? – Arkane Oct 22 '21 at 10:23
  • I tried calling it in the constructor. And no, this is not the case, IsActive is a property in every ViewModel. It works well, there are no problems except the fact that the first radio button is not preselected. When I open the app, if I click on the button it changes the border, but I want this to happen without me clicking on it - I want it to be preselected. – Melissa Oct 22 '21 at 10:28
  • I think what needs to be done is to somehow trigger the things inside without actually clicking on the button. – Melissa Oct 22 '21 at 10:32
  • I'm assuming (you didn't specify) that your issue is that your radio button isn't initially selected, as opposed to the button being selected but the style not applying. Could you show us where your declare IsActive, and where you set it to true? Seems to me like a simple binding issue - probably some form of race condition. You might also want to check if you get any binding errors (in VS, you might need to enable them first). FirstRadioButton.IsChecked = true doing nothing is expected, as it should be overriden by the binding. – Shrimperator Oct 22 '21 at 10:51
  • 1
    And Arkane has a point: both IsActive properties you are binding to are in the same DataContext, so even if every ViewModel has its own IsActive, you're binding both radio buttons to the same one as far as I can tell. – Shrimperator Oct 22 '21 at 10:56
  • Yes, that is correct. IsActive is a property from an interface, called IPage. Every ViewModel implements this interface. I don’t know where to set IsActive of the first radio button’s ViewModel to true. That is my question. (apparently not from the constructor) I will edit the question with another approach I have tried. – Melissa Oct 22 '21 at 11:02
  • 1
    you need to handle event or method like "OnNavigatedTo", There you must set IsActive property as true of related viewmodel. Don't do anything in xaml.cs – Ugur Oct 22 '21 at 11:15

1 Answers1

1

The data binding from the RadioButton.IsChecked to the IsActive property is wrong. It should trigger a binding error in your IDE. The binding tries to find a MainViewModel.IsActive property, which doesn't exist. Therefore, setting

firstViewModel.IsActive = true

has no effect on the view/data binding.


The ItemsControl you use to host the StackPanel is pretty useless: it contains a single item - a StackPanel containing all the buttons. In general, avoid the ItemsControl and choose the more advanced ListBox over it. It has some important perfomance features like UI virtualization.

Using an ItemsControl, or better a ListBox, to host the collection of RadioButton elements is a good idea, but executed poorly.

You should create data models for the navigation buttons, which will be espeacially handy when you add more pages and hence more navigation buttons. A IsNavigating property on this model allows to control the state of the button that binds to this property.

The pattern is the same like the one you have used for the pages: View-Model-First. Create data models first and let WPF render the related views dynamically by defining one or more DataTemplate. In this case the ListBox will generate the view for you.
This is the main concept of WPF: think of data first. That's what the idea of DataTemplate is about.

The IPage.IsActive property of your page models should not bind to the navigation buttons directly. If you really need this property, then in the MainViewModl reset this property on the old page model before you replace the SelectedPage value (or how you have named the property that exposes the currently active page model) and set this property on the new page model after assigning it to the SelectedPage property. Let the model that hosts and exposes the pages handle and control the complete navigation logic.
Although this logic triggers the view, it is a pure model related logic. Therefore, you should not split up this logic and move parts of it to the view (e.g., by data binding to the view ie. making the logic depend on buttons).
You could even extract this logic to a new class e.g., PageModelController that the MainViewModel then can use and expose for data binding.
Consider to convert the IPage.IsActive property to a read-only property and add a IPage.Activate() and IPage.Dactivate() method, if changing the IsActive state involvs invoking operations.

NavigationItem.cs
A navigation button data model.

class NavigationItem : INotifyPropertyChanged
{
  public NavigationItem(string name, PageName pageId, bool isNavigating = false)
  {
    this.Name = name;
    this.IsNavigating = isNavigating;
    this.PageId = pageId;
  }

  public string Name { get; }
  public PageName PageId { get; }

  private bool isNavigating 
  public bool IsNavigating 
  { 
    get => this.isNavigating;
    set
    {
      this.isNavigating = value;
      OnPropertyChanged();
    }
  }

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

INavigationItemFactory.cs
Since you use dependency injection, you should define an abstract factory to create instances dynamically. If creating a NavigationItem would require less than three arguments, I would have chosen a Func delegate instead of a dedicated factory class (for the sake of readability).

interface INavigationItemFactory
{
  NavigationItem Create(string name, PageName pageId, bool isNavigating = false);
}

NavigationItemFactory.cs

class NavigationItemFactory
{
  public NavigationItem Create(string name, PageName pageId, bool isNavigating = false)
    => new NavigationItem(name, pageId, isNavigating);
}

MainViewModel.cs
Create the data models of the radio buttons.

class MainViewModel : INotifyPropertyChanged
{
  public ObservableCollection<NavigationItem> NavigationItems { get; }
  private INavigationItemFactory NavigationItemFactory { get; }

  public MainViewModel(INavigationItemFactory navigationItemFactory)
  {
    this.NavigationItemFactory = navigationItemFactory;
    this.NavigationItems = new ObservableCollection<NavigationItem>
    {
      this.NavigationItemFactory.Create("First Page", PageName.FirstView, true), // Preselect the related RadioButton
      this.NavigationItemFactory.Create("Second Page", PageName.SecondView),
      this.NavigationItemFactory.Create("Third Page", PageName.ThirdView)
    };
  }

  // Handle page selection and the IsActive state of the pages.
  // Consider to make the IsActive property read-only and add Activate() and Dactivate() methods, 
  // if changing this state involvs invoking operations.
  public void SelectPage(object param)
  {
    if (param is PageName pageName 
      && this.Pages.TryGetValue(pageName, out IPage selectedPage))
    {
      // Deactivate the old page
      this.SelectedPage.IsActive = false;
      this.SelectedPage = selectedPage;

      // Activate the new page
      this.SelectedPage.IsActive = true;
    }
  }
}

MainView.xaml
The example expects that MainViewModel is the DataContext of MainView.

<Window>

  <!-- Navigation bar (vertical - to change it to horizontal change the ListBox.ItemPanel) -->
  <ListBox ItemsSource="{Binding NavigationItems}">
    <ListBox.ItemTemplate>
      <DataTemplate DataType="{x:Type local:NavigationItem}">
        <RadioButton GroupName="PageNavigationButtons" 
                     Content="{Binding Name}" 
                     IsChecked="{Binding IsNavigating}" 
                     CommandParameter="{Binding PageId}"
                     Command="{Binding RelativeSource={RelativeSource AncestorType=ListBox}, Path=DataContext.ShowPageCommand}" />
      </DataTemplate>
    </ListBox.ItemTemplate>

    <!-- Remove the ListBox look&feel by overriding the ControlTemplate of ListBoxItem -->
    <ListBox.ItemContainerStyle>
      <Style TargetType="ListBoxItem">
        <Setter Property="Template">
          <Setter.Value>
            <ControlTemplate TargetType="ListBoxItem">
              <ContentPresenter />
            </ControlTemplate>
          </Setter.Value>
        </Setter>
      </Style>
    </ListBox.ItemContainerStyle>
  </ListBox>
</Window>

You can also simplify your RadioButtonStyle.
Generally, when your triggers target elements that are part of the ControlTemplate, it's best to use the common ControlTemplate.Triggers instead of Style.Triggers for each individual element. It's also cleaner to have all triggers in one place instead of them being scattered throughout the template, only adding noise to the layout:

<Style x:Key="RadioButtonStyle" TargetType="RadioButton">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type RadioButton}">
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
          </Grid.ColumnDefinitions>
          <Border x:Name="IsActiveIndicator" 
                  Grid.Column="0"
                  Background="Transparent" 
                  Width="10" />
          <Border Grid.Column="1"
                  Background="{TemplateBinding Background}"
                  BorderBrush="{TemplateBinding BorderBrush}"
                  BorderThickness="{TemplateBinding BorderThickness}">
            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
          </Border>
        </Grid>

        <ControlTemplate.Triggers>
          <Trigger Property="IsChecked" Value="True">
            <Setter TargetName="IsActiveIndicator" Property="Background" Value="#D50005" />
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

App.xaml.cs
Then in your application's entry point register the INavigationItemFactory factory implementation with your IoC container.

var services = new ServiceCollection();
services.AddSingleton<INavigationItemFactory, NavigationItemFactory>();
BionicCode
  • 1
  • 4
  • 28
  • 44
  • Lifesaver! Works amazing! Thank you very much! – Melissa Oct 23 '21 at 18:39
  • Hello again, sorry for bothering you once again, but I have a question regarding the navigation. I have a menu with 3 items and when clicking on the second one, the page that is loaded should contain a menu as well. I am using this implementation once again, it works great, but the second item gets deselected when clicking on it and the first item of the new page's menu gets selected. I want to keep the second item selected as well, to show which page I am now. I understand why it gets deselected, but I haven't figured out a way to solve that. Do you, by chance, have any suggestions? – Melissa Nov 08 '21 at 16:43
  • 1
    Hi, you are welcome. I might need to see your implementation. I can only guess, but what comes first into my mind is whether you are using the same RadioButton.GroupName for both menus? Each menu must use their own group names. There can only be one RadioButton selected in the scope of a particular GroupName. – BionicCode Nov 08 '21 at 18:27
  • That was exactly the problem! Sorry for bothering you and thanks a lot again! :) – Melissa Nov 08 '21 at 20:04