Search code examples
c#wpfmvvm

WPF: How to recalculate CanExecute in ICommand in one ViewModel after changes in another ViewModel?


I have a WPF app with two buttons: Search and Properties.

MainWindow.xaml

<WrapPanel>
    <Button Content="Search Data" Command="{Binding SearchCommand}" />
    <Button Content="Properties" Command="{Binding PropertiesCommand}" />
</WrapPanel>

Both buttons use commands that initialized in MainWindowViewModel:

    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public MainWindowViewModel()
        {
            SearchCommand = new SearchDataCommand(this);
            PropertiesCommand = new RelayCommand(OpenPropertiesWindow);
        }
        
        private async Task OpenPropertiesWindow()
        {
            PropertiesWindow propertiesWindow = new PropertiesWindow();
            propertiesWindow.Owner = Application.Current.MainWindow;
            propertiesWindow.ShowDialog();
        }
    }

When you click on the Search button, SearchCommand is called. CanExecute method of SearchCommand checks that 2 properties are set:

SearchDataCommand:

    public override bool CanExecute(object parameter)
    {
        if (string.IsNullOrEmpty(Properties.Settings.Default.ApiKey))
        {
            // show message box that property is not set
            return false;
        } 
        
        if (string.IsNullOrEmpty(Properties.Settings.Default.UserId))
        {
            // show message box that property is not set
            return false;
        }

        return !IsExecuting;
    }

When I click Properties button, I open properties window where I can set these properties (ApiKey and UserId).

    public class PropertiesViewModel : INotifyPropertyChanged
    {
        //...

        public PropertiesViewModel()
        {
            SaveCommand = new RelayCommand(SaveData, null);
            
            ApiKey = Properties.Settings.Default.ApiKey;
            UserId = Properties.Settings.Default.UserId;
        }

        private async Task SaveData()
        {
            Properties.Settings.Default.ApiKey = ApiKey;
            Properties.Settings.Default.UserId = UserId;
            Properties.Settings.Default.Save();

            SaveCompleted?.Invoke(this, EventArgs.Empty); //Event is handled in PropertiesWindow to call Close() method
        }
    }

Passing one ViewModel to another ViewModel is not a good idea from MVVM point of view since it leads to unneccessary coupling between view models.

And here is the question: I need to recalculate CanExecute in SearchDataCommand (to activate the button) when ApiKey and UserId properties are set and saved in PropertiesViewModel? How to do it correctly without breaking MVVM principles?

Updated according to the @BionicCode recommendations.

MainWindow:

    public partial class MainWindow
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void PropertiesBtn_OnClick(object sender, RoutedEventArgs e)
        {
            PropertiesWindow propertiesWindow = new PropertiesWindow
            {
                Owner = this
            };
            propertiesWindow.ShowDialog();
        }
    }

PropertiesWindow:

    public partial class PropertiesWindow : Window
    {
        private readonly PropertiesViewModel _propertiesViewModel;
        
        public PropertiesWindow()
        {
            InitializeComponent();

            _propertiesViewModel = new PropertiesViewModel();
            DataContext = _propertiesViewModel;

            _propertiesViewModel.SettingsRepository.SaveCompleted += PropertiesViewModel_SaveCompleted;
        }

        private void PropertiesViewModel_SaveCompleted(object sender, EventArgs e)
        {
            Close();
        }
    }

SettingsRepository:

    public class SettingsRepository
    {
        public string ReadApiKey() => Properties.Settings.Default.ApiKey;
        public string ReadUserId() => Properties.Settings.Default.UserId;
        
        public void WriteApiKey(string apiKey)
        {
            Properties.Settings.Default.ApiKey = apiKey;
            PersistData();
        }

        public void WriteUserId(string userId)
        {
            Properties.Settings.Default.UserId = userId;
            PersistData();
        }

        private void PersistData()
        {
            Properties.Settings.Default.Save();
            OnSaveCompleted();
        }

        public event EventHandler SaveCompleted;
        private void OnSaveCompleted() => SaveCompleted?.Invoke(this, EventArgs.Empty);
    }

SearchCommand:

    public class SearchCommand : ICommand
    {
        private bool _isExecuting;

        public bool IsExecuting
        {
            get => _isExecuting;
            set
            {
                _isExecuting = value;
                OnCanExecuteChanged();
            }
        }

        public SearchCommand(Action<object> executeSearchCommand, Func<object, bool> canExecuteSearchCommand)
        {
            ExecuteSearchCommand = executeSearchCommand;
            CanExecuteSearchCommand = canExecuteSearchCommand;
        }

        public void InvalidateCommand() => OnCanExecuteChanged();

        private Func<object, bool> CanExecuteSearchCommand { get; }
        private Action<object> ExecuteSearchCommand { get; }

        private event EventHandler CanExecuteChangedInternal;
        public event EventHandler CanExecuteChanged
        {
            add
            {
                CommandManager.RequerySuggested += value;
                CanExecuteChangedInternal += value;
            }

            remove
            {
                CommandManager.RequerySuggested -= value;
                CanExecuteChangedInternal -= value;
            }
        }
        
        public bool CanExecute(object parameter) => CanExecuteSearchCommand?.Invoke(parameter) ?? !IsExecuting;
        public void Execute(object parameter) => ExecuteSearchCommand(parameter);

        private void OnCanExecuteChanged() => CanExecuteChangedInternal?.Invoke(this, EventArgs.Empty);
    }

MainViewModel:

    public class MainViewModel : INotifyPropertyChanged
    {
        public SearchCommand SearchCommand { get; }
        private SettingsRepository SettingsRepository { get; }
        
        private readonly ISearchService _searchService;

        #region INotifyProperties
        //properties
        #endregion

        public MainViewModel(ISearchService searchService)
        {
            _searchService = searchService;
            
            SettingsRepository = new SettingsRepository();
            SettingsRepository.SaveCompleted += OnSettingsChanged;
            
            SearchCommand = new SearchCommand(ExecuteSearchCommand, CanExecuteSearchDataCommand);
        }

        private bool CanExecuteSearchDataCommand(object parameter)
        {
            if (string.IsNullOrEmpty(SettingsRepository.ReadApiKey()))
            {
                MessageBox.Show(
                    "Set API Key in the application properties.",
                    "Configuration Error",
                    MessageBoxButton.OK,
                    MessageBoxImage.Error);
                return false;
            } 
            
            if (string.IsNullOrEmpty(SettingsRepository.ReadUserId()))
            {
                MessageBox.Show(
                    "Set User id in the application properties.",
                    "Configuration Error",
                    MessageBoxButton.OK,
                    MessageBoxImage.Error);
                return false;
            }

            return !SearchCommand.IsExecuting;
        }

        private async void ExecuteSearchCommand(object parameter)
        {
            SearchCommand.IsExecuting = true;
            await ExecuteSearchCommandAsync(parameter);
            SearchCommand.IsExecuting = false;
        }

        private async Task ExecuteSearchCommandAsync(object parameter)
        {
            //Search logic with setting INotifyProperties with results
        }
        
        private void OnSettingsChanged(object sender, EventArgs e) => SearchCommand.InvalidateCommand();

        public event PropertyChangedEventHandler PropertyChanged;

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

PropertiesViewModel:

    public class PropertiesViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        public ICommand SaveCommand { get; }
        public SettingsRepository SettingsRepository { get; }
        
        #region INotifyProperties
        //properties
        #endregion

        public PropertiesViewModel()
        {
            SettingsRepository = new SettingsRepository();
            
            ApiKey = SettingsRepository.ReadApiKey();
            UserId = SettingsRepository.ReadUserId();
            
            SaveCommand = new RelayCommand(SaveData, null);
        }

        private async Task SaveData()
        {
            SettingsRepository.WriteApiKey(ApiKey);
            SettingsRepository.WritemUserId(UserId);
        }

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

Still I'm not sure that current classes design doesn't violate MVVM principles. At the end I get some sort of result. But here are the things that confuse me:

  • View models have separate SettingsRepository objects. Is it OK that MainViewModel subscribes to SettingsRepository.SaveCompleted to call InvalidateCommand and PropertiesViewModel subscribes to its own instance just for closing the PropertiesWindow? It looks like some sort of mess.
  • For some reason, CanExecuteSearchDataCommand is called twice and it seems that InvalidateCommand does too much work for the price of loose coupling here. I mean it seems it reacts on the MessageBox closing. If I remove MessageBox and just return false, then it works as expected.
  • Are there any resources to read about these mechanisms and how to properly use them (like InvalidateCommand) in details, except for MSDN?

Solution

  • Your current solution is breaking the MVVM pattern and therefore creates the issue you are currently struggle to solve.

    • The View Model is not allowed to handle/reference controls or participate in UI logic. This means, showing dialogs from the View Model is not allowed. Everything UI related (dialogs are UI) must be handled in the View.
    • The View Model is not responsible to persist data. This is the responsibility of the Model.

    The following solution solves your problem gracefully by fixing the MVVM violation and by improving the class design:

    1. Because data persistence is the responsibility of the Model you usually would wrap the actual read/write operations into a class that the View Model classes can use, preferably as a shared instance. This allows any class (like MainWindowViewModel for example) to observe data changes of the data repository. This is now possible because read/write operations are no longer spread across the application. The View Model should not know any details about the underlying data store that the Model is using (whether data is persisted using the file system or a database for example).

    2. Then let your command expose a InvalidateComand() method that explicitly raises the ICommand.CanExecuteChanged event.
      Note that when delegating the CanExecuteChanged event to the CommandManager.RequeryREquested event, WPF will automatically invoke ICommand.CanExecute (for example on mouse move).

    This way both your classes MainWindowViewModel and PropertiesViewModel are completely independent of each other. Both depend on the repository: on class to write to the user settings and the other to observe data changes.

    MainWindowViewModel.cs

    class MainWindowViewModel
    {
      public MainWindowViewModel(UserSettingsRepository userSettingsRepository)
      {
        // Use the Model to read and write data
        this.UserSettingsRepository = userSettingsRepository;
    
        // Because the repository is used application wide we now have a single object to observe for setting changes.
        // When the repository reports changes, we explicitly invalidate the command
        this.UserSettingsRepository.SaveCompleted += OnUserSettingsChanged;
    
        this.SearchDataCommand = new SearchDataCommand(ExecuteSearchDataCommand, CanExecuteSearchDataCommand);
      }
    
      private void OnUserSettingsChnaged(object sender, EventArgs e) => this.SearchDataCommand.InvalidateCommand();
    
      private void ExecuteSearchDataCommand(object? obj) => throw new NotImplementedException();
      private bool CanExecuteSearchDataCommand(object? arg) => throw new NotImplementedException();
    
      public SearchDataCommand SearchDataCommand { get; }
      private UserSettingsRepository UserSettingsRepository { get; }
    }
    

    SearchDataCommand.cs

    class SearchDataCommand : ICommand
    {
      public SearchDataCommand(Action<object?> executeSearchDataCommand, Func<object?, bool> canExecuteSearchDataCommand)
      {
        this.ExecuteSearchDataCommand = executeSearchDataCommand;
        this.CanExecuteSearchDataCommand = canExecuteSearchDataCommand;
      }
    
      public void InvalidateCommand() => OnCanExecuteChanged();
    
      public bool CanExecute(object? parameter) => this.CanExecuteSearchDataCommand?.Invoke(parameter) ?? true;
      public void Execute(object? parameter) => this.ExecuteSearchDataCommand(parameter);
    
      private void OnCanExecuteChanged() => this.CanExecuteChangedInternal?.Invoke(this, EventArgs.Empty);
    
      public event EventHandler? CanExecuteChanged
      {
        add
        {
          CommandManager.RequerySuggested += value;
          this.CanExecuteChangedInternal += value;
        }
        remove
        {
          CommandManager.RequerySuggested -= value;
          this.CanExecuteChangedInternal -= value;
        }
      }
    
      private event EventHandler CanExecuteChangedInternal;
      private Action<object?> ExecuteSearchDataCommand { get; }
      private Func<object?, bool> CanExecuteSearchDataCommand { get; }
    }
    

    UserSettingsRepository.cs

    // A class of the Model (MVVM)
    class UserSettingsRepository
    {
      public void WriteApiKey(string apiKey)
      {
        Properties.Settings.Default.ApiKey = apiKey;
        PersistUserData();
      }
    
      public void WriteUserId(string userId)
      {
        Properties.Settings.Default.UserId = userId;
        PersistUserData();
      }
    
      public string ReadApiKey(string apiKey) => Properties.Settings.Default.ApiKey = apiKey;
    
      public string ReadUserId(string userId) => Properties.Settings.Default.UserId = userId;
    
      private PersistUserData()
      {
        Properties.Settings.Default.Save();
        OnSaveCompleted(); 
      }
    
      private void OnSaveCompleted => SaveCompleted?.Invoke(this, EventArgs.Empty); //Event is handled in PropertiesWindow to call Close() method    
    }
    

    MainWindow.xaml.cs

    partial class MainWindow : Window
    {
      private void OnShowPropertiesDialogButtonClicked(object sender, RoutedEventArgs e)
      {
        // TODO::Show PropertiesWindow dialog
      }
    }
    

    PropertiesViewModel.cs

    class PropertiesViewModel : INotifyPropertyChanged
    {
      public PropertiesViewModel(UserSettingsRepository userSettingsRepository)
      {
        // Use the Model to read and write data
        this.UserSettingsRepository = userSettingsRepository;   
      }
    
      private void ExecuteSaveDataCommand(object parameter)
      {
        this.UserSettingsRepository.WriteApiKey(this.ApiKey); 
        this.UserSettingsRepository.WriteUserId(this.UserId); 
      }
    
      public SaveCommand SaveCommand { get; }
      private UserSettingsRepository UserSettingsRepository { get; }
    }