Reputation: 2481
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:
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.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.InvalidateCommand
) in details, except for MSDN?Upvotes: 1
Views: 683
Reputation: 29028
Your current solution is breaking the MVVM pattern and therefore creates the issue you are currently struggle to solve.
The following solution solves your problem gracefully by fixing the MVVM violation and by improving the class design:
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).
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; }
}
Upvotes: 2
Reputation: 949
As SearchDataCommand
is depending on MainWindowViewModel
and the viewmodel can be changed after it is passed to the command, there should be a way to inform the command about the fact that the viewmodel changed, so it can issue a CanExecuteChanged
on its own.
I'd add a Changed
event to the viewmodel, trigger that on every call to PropertyChanged
and subscribe to it in the command.
EDIT:
If the PropertiesViewModel
is just used in the PropertiesWindow
and has no connection to anything (regarding the scope of this post), you should think of some messaging functionality.
Create a PropertiesChangedMessage
and publish it from the PropertiesViewModel
. Then subscribe to the message in the SearchCommand
and trigger the CanExecuteChanged
event.
For such messaging have a look at the community toolkit.
Upvotes: 2