Home > Software engineering >  Preselect Radio Button WPF MVVM
Preselect Radio Button WPF MVVM

Time:10-24

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?

CodePudding user response:

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>();
  • Related