Search code examples
wpfmvvmdelegates

WPF - How to change ContentControl view from sub-view?


I'm new to WPF/MVVM and building an application which runs "Jobs". The aplication currently looks like this: enter image description here

The MainView contains some buttons and a ContentControl. The ContentControl shows some sub-views like JobsView or ApplicationSettingsView, depending on the selected menu item.

The JobsView contains blocks which represents a job. It shows some job information, job functions and an edit button (Bewerk) to edit/configure the job.

When i click on the button "Bewerk", a job specific edit view should be shown in the ContentControl of the MainView. I'm looking for the best way to handle this navigation. Important is that i have the instance of the concerning job available in the JobEditViewModel.

I tried:

  1. Passing a function to the JobsViewModel (https://linuxhint.com/passing-function-as-parameter-c-sharp/) when its created in the MainViewModel. This does not work as the JobsViewModel is called twice somehow (noticed during debugging). One time from the MainViewModel and one time from ???. The second time, it calls the constructor without params so i cannot call the passed function. After some research i also read that its bad practice to pass params to ViewModel constructors.
...
namespace ECRO.MVVM.ViewModel
{
    internal class MainViewModel : ObservableObject
    {
        private object _currentView;
        private Engine engine;

        public JobsViewModel JobsVm { get; set; }
        public SettingsViewModel SettingsVm { get; set; }

        public RelayCommand JobViewCommand { get; set; }
        public RelayCommand SettingsViewCommand { get; set; }


        public object CurrentView
        {
            get { return _currentView; }
            set
            {
                _currentView = value;
                OnPropertyChanged();
            }
        }

        public MainViewModel()
        {
            Task.Run(() =>
            {
                engine = Engine.Instance;
            });

            JobsVm = new JobsViewModel((viewModel) => { this._currentView = viewModel; });
            SettingsVm = new SettingsViewModel();

            _currentView = JobsVm;

            // Initialize relay commands
            JobViewCommand = new RelayCommand(o => { CurrentView = JobsVm; });
            SettingsViewCommand = new RelayCommand(o => { CurrentView = SettingsVm; });
        }
    }
}
...
namespace ECRO.MVVM.ViewModel
{
    class JobsViewModel
    {
        private ObservableCollection<RJob> _jobs;

        public ObservableCollection<RJob> Jobs { get => _jobs; set { _jobs = value; } }

        public RelayCommand ForceRunJobOnceCmd { get; set; }
        public RelayCommand StartJobCmd { get; set; }
        public RelayCommand StopJobCmd { get; set; }
        public RelayCommand EnableJobCmd { get; set; }
        public RelayCommand DisableJobCmd { get; set; }
        public RelayCommand EditJobCmd { get; set; }

        public string btnEnableDisableContent { get => "test"; }

        public JobsViewModel() {

        }

        public JobsViewModel(Action<BaseViewModel> changeContentView)
        {
            _jobs = Engine.Instance.RJobs;

            ForceRunJobOnceCmd = new RelayCommand(o => { ((RJob)o).ForceRunOnce(); });
            StartJobCmd = new RelayCommand(o => { ((RJob)o).Start(); });
            StopJobCmd = new RelayCommand(o => { ((RJob)o).Stop(); });
            EnableJobCmd = new RelayCommand(o => { ((RJob)o).Enable(); });
            DisableJobCmd = new RelayCommand(o => { ((RJob)o).Disable(); });
            EditJobCmd = new RelayCommand(o => { changeContentView(new VerzamelqueryViewModel()); });
        }
    }
}
  1. Using Events and Delegates. (https://www.tutorialsteacher.com/csharp/csharp-event) This does also not work. The event handler in MainView is not called/triggered for some reason. It also feels not nice to go for this solution as i have no idea how i can register an event handler when the sub-view is not created/initiated from the MainView but from another sub-view. (e.g. the EditJobView will get a sub-sub view to show logs of the concerning Job and include a back button to go back to the EditJobView) Its also feels not realy generic/reusable. (need to implement a new event and event handler for each sub-view that handles navigation)
...
namespace ECRO.MVVM.ViewModel
{
    internal class MainViewModel : ObservableObject
    {
        private object _currentView;
        private Engine engine;

        public JobsViewModel JobsVm { get; set; }
        public SettingsViewModel SettingsVm { get; set; }

        public RelayCommand JobViewCommand { get; set; }
        public RelayCommand SettingsViewCommand { get; set; }


        public object CurrentView
        {
            get { return _currentView; }
            set
            {
                _currentView = value;
                OnPropertyChanged();
            }
        }

        public MainViewModel()
        {
            Task.Run(() =>
            {
                engine = Engine.Instance;
            });

            //JobsVm = new JobsViewModel((viewModel) => { });
            JobsVm = new JobsViewModel();
            JobsVm.ChangeView += (object sender, BaseViewModel newView) => { _currentView = newView; };
            SettingsVm = new SettingsViewModel();

            _currentView = JobsVm;

            // Initialize relay commands
            JobViewCommand = new RelayCommand(o => { CurrentView = JobsVm; });
            SettingsViewCommand = new RelayCommand(o => { CurrentView = SettingsVm; });
        }
    }
}
...
namespace ECRO.MVVM.ViewModel
{
    class JobsViewModel
    {
        private ObservableCollection<RJob> _jobs;

        public ObservableCollection<RJob> Jobs { get => _jobs; set { _jobs = value; } }

        public RelayCommand ForceRunJobOnceCmd { get; set; }
        public RelayCommand StartJobCmd { get; set; }
        public RelayCommand StopJobCmd { get; set; }
        public RelayCommand EnableJobCmd { get; set; }
        public RelayCommand DisableJobCmd { get; set; }
        public RelayCommand EditJobCmd { get; set; }

        public string btnEnableDisableContent { get => "test"; }

        public event EventHandler<BaseViewModel> ChangeView;

        public JobsViewModel()
        {
            _jobs = Engine.Instance.RJobs;

            ForceRunJobOnceCmd = new RelayCommand(o => { ((RJob)o).ForceRunOnce(); });
            StartJobCmd = new RelayCommand(o => { ((RJob)o).Start(); });
            StopJobCmd = new RelayCommand(o => { ((RJob)o).Stop(); });
            EnableJobCmd = new RelayCommand(o => { ((RJob)o).Enable(); });
            DisableJobCmd = new RelayCommand(o => { ((RJob)o).Disable(); });
            EditJobCmd = new RelayCommand(o => { ChangeView?.Invoke(this, new VerzamelqueryViewModel()); }); 
        }
    }
}

Whats the best practice/WPF-MVVM way to handle my case?

------- Udate: Views in case they are needed ...

MainView:

<Window x:Class="ECRO.MVVM.View.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:ECRO.MVVM.View"
        xmlns:viewModel="clr-namespace:ECRO.MVVM.ViewModel"
        mc:Ignorable="d"
        Height="880" Width="1000"
        WindowStyle="None"
        ResizeMode="NoResize"
        Background="Transparent"
        AllowsTransparency="True"
        Title="MainWindow">

    <Window.DataContext>
        <viewModel:MainViewModel/>
    </Window.DataContext>

    <Border
        Background="#272537"
        CornerRadius="20">
        <WrapPanel>
            <Border Height="35"
                       Width="1000"
                       Margin="0"
                    CornerRadius="20 20 0 0"
                       Background="#22202f">
                <DockPanel>
                    <DockPanel Width="930" 
                               Background="Transparent"
                               PreviewMouseDown="drawWindow">
                        <TextBlock Text="ECRO - ECB Robot" Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="15" FontWeight="Bold"/>
                    </DockPanel>
                    <DockPanel HorizontalAlignment="Right"
                               Margin="0 5 10 0">
                        <Button Content="—"
                            Width="20" Height="20"
                            Margin="5 5 5 5"
                            Background="Transparent"
                            BorderThickness="0"
                            Foreground="White"
                            FontSize="15"
                            Click="minimizeApplication"/>
                        <Button Content="X"
                            Width="20" Height="20"
                            Margin="5 5 5 5"
                            Background="Transparent"
                            BorderThickness="0"
                            Foreground="White"
                            FontSize="15"/>
                    </DockPanel>
                </DockPanel>

            </Border>

            <Grid Height="845" Width="1000">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="200"/>
                    <ColumnDefinition/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="50"/>
                    <RowDefinition/>
                </Grid.RowDefinitions>

                <TextBlock Text="ECB Robot"
                       Grid.Row="0"
                       VerticalAlignment="Center"
                       HorizontalAlignment="Left"
                       Foreground="White"
                       FontSize="22"
                       Margin="20 0 0 0"/>

                <StackPanel Grid.Row="1" 
                        Margin="0 10 0 0">
                    <RadioButton Content="Jobs"
                             Style="{StaticResource MenuButton}"
                             IsChecked="True"
                             Command="{Binding JobViewCommand}"
                             />
                    <RadioButton Content="Settings"
                             Style="{StaticResource MenuButton}"
                             Command="{Binding SettingsViewCommand}"/>
                    <RadioButton Content="Stop robot"
                             Style="{StaticResource MenuButton}"/>

                </StackPanel>

                <TextBox Width="250" 
                     Height="40"
                     VerticalContentAlignment="Center"
                     HorizontalAlignment="Left"
                     Margin="5"
                     Grid.Row="0"
                     Grid.Column="1"
                     Style="{StaticResource Textbox}"/>

                <ContentControl Grid.Row="1"
                            Grid.Column="1"
                            Margin="10"
                            Content="{Binding CurrentView}"/>

            </Grid>
        </WrapPanel>
    </Border>
</Window>

JobsView:

<UserControl x:Class="ECRO.MVVM.View.JobsView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:viewModel="clr-namespace:ECRO.MVVM.ViewModel"
             xmlns:widget="clr-namespace:ECRO.MVVM.Widgets"
             mc:Ignorable="d" d:DesignWidth="800" Height="780">

    <UserControl.DataContext>
        <viewModel:JobsViewModel/>
    </UserControl.DataContext>

    <StackPanel>
        <TextBlock Text="Jobs" Style="{StaticResource HeaderTextBlock}"/>
        <ScrollViewer Width="780" Height="700">
            <ItemsControl ItemsSource="{Binding Path=Jobs}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <StackPanel Orientation="Vertical"/>
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemTemplate>
                    <DataTemplate DataType="{x:Type viewModel:JobWidgetViewModel}">
                        <widget:JobWidget/>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </ScrollViewer>
    </StackPanel>
</UserControl>

JobWidget:

<UserControl x:Class="ECRO.MVVM.Widgets.JobWidget"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" 
             d:DesignHeight="110" d:DesignWidth="760">
    
    <!--<UserControl.DataContext>
        <viewModel:JobWidgetViewModel/>
    </UserControl.DataContext>-->

    <Grid>
        <Grid Height="110" Width="760">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="677*"/>
                <ColumnDefinition Width="123*"/>
            </Grid.ColumnDefinitions>
            <Border CornerRadius="10"
                    Background="#353340"
                    Height="100"
                    Margin="10,0,10,0"
                    VerticalAlignment="Center" Grid.ColumnSpan="2">
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="110"/>
                        <ColumnDefinition Width="160"/>
                        <ColumnDefinition Width="*"/>
                    </Grid.ColumnDefinitions>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="50*"/>
                        <!--<RowDefinition Height="50*"/>-->
                    </Grid.RowDefinitions>

                    <StackPanel Orientation="Vertical"
                                    Margin="5 2 0 0">
                        <Label Content="Naam" FontWeight="Bold" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="Status" FontWeight="Bold" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="Laatste run" FontWeight="Bold" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="Volgende run" FontWeight="Bold" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="Schema" FontWeight="Bold" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                    </StackPanel>
                    <StackPanel Grid.Column="1" Margin="2">
                        <Label Content="{Binding Name}" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="{Binding Status}"  Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="{Binding LastRun}" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="{Binding NextRun}" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                        <Label Content="{Binding Schedule.Type }" Style="{StaticResource DefaultLabel}" Margin="0 -5 0 -5"/>
                    </StackPanel>


                    <Border Grid.RowSpan="2"
                                    Grid.Column="2"
                                    Grid.Row="0"
                                    HorizontalAlignment="Right"
                                    Margin="0 0 20 0">
                        <StackPanel Orientation="Horizontal">
                            <Button Content="Forceer run" 
                                        Style="{StaticResource DarkButton}" 
                                        Width="100"
                                        Command="{Binding DataContext.ForceRunJobOnceCmd, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
                                        CommandParameter="{Binding}"/>
                            <Button Content="Start" 
                                        Style="{StaticResource DarkButton}" 
                                        Command="{Binding DataContext.StartJobCmd, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
                                        CommandParameter="{Binding}"/>
                            <Button Content="Stop" 
                                        Style="{StaticResource DarkButton}" 
                                        Command="{Binding DataContext.StopJobCmd, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
                                        CommandParameter="{Binding}"/>
                            <Button x:Name="btnEnable"
                                        Content="Enable"
                                        Style="{StaticResource DarkButton}" 
                                        Command="{Binding DataContext.EnableJobCmd, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
                                        CommandParameter="{Binding}"/>
                            <Button x:Name="btnDisable"
                                        Content="Disable"
                                        Style="{StaticResource DarkButton}" 
                                        Command="{Binding DataContext.DisableJobCmd, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
                                        CommandParameter="{Binding}"/>
                            <Button Content="Bewerk" 
                                        Style="{StaticResource DarkButton}" 
                                        Command="{Binding Main.EditJobCmd, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
                                        CommandParameter="{Binding}"/>
                        </StackPanel>
                    </Border>
                </Grid>
            </Border>
        </Grid>
    </Grid>
</UserControl>

Solution

  • You could use an event aggregator or a messenger to raise events or pass messages from one view model to another. The concept is explained in this blog post.

    The idea is that the JobsViewModel raises a "navigation" event that the MainViewModel handles without introducing a direct dependency between the view models.

    Another option is to inject JobsViewModel with an INavigate or INavigationService interface that is implemented to switch the view, for example by doing what the MainViewModel currently does.

    A third option is to bind directly to a command of the MainViewModel from the JobsView using a {RelativeSource}:

    <Button Content="..." Command="{Binding Path=DataContext.SwitchViewCommand, 
        RelativeSource={RelativeSource AncestorType=Window}}" />