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:
Giving the first radio button a name, e.g. FirstRadioButton, and calling
FirstRadioButton.IsChecked = true
inMainView.xaml.cs
. Nothing is triggered.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>();