A C# MVVM toolkit library that extends CommunityToolkit.Mvvm with additional strongly-typed abstractions for building robust MVVM applications.
# Package manager
Install-Package OliveStudio.Toolkit
# .NET CLI
dotnet add package OliveStudio.Toolkit
This library extends:
- CommunityToolkit.Mvvm - For base MVVM functionality
- OliveStudio.Helpers - For async event handler delegates
A generic base class that combines CommunityToolkit.Mvvm's ObservableObject
with a strongly-typed model, providing a clean separation between your view models and domain models.
public abstract class ObservableObject<TModel> : ObservableObject
{
public TModel Model { get; }
protected ObservableObject(TModel model) { }
protected ObservableObject() { }
}
Benefits:
- Strongly-typed access to your domain model
- Inherits all CommunityToolkit.Mvvm observable functionality
- Clear separation of concerns between UI and business logic
A strongly-typed command interface that accepts a specific parameter type, providing better type safety than the standard ICommand
.
public interface ICommand<in T>
{
event EventHandler CanExecuteChanged;
bool CanExecute(T parameter);
void Execute(T parameter);
}
An asynchronous command interface for handling async operations with strongly-typed parameters.
public interface ICommandAsync<in T>
{
event AsyncEventHandler CanExecuteChanged;
bool CanExecute(T parameter);
void Execute(T parameter);
}
Note: Uses AsyncEventHandler
from OliveStudio.Helpers for async event handling.
// Domain model
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
}
// View model
public class UserViewModel : ObservableObject<User>
{
public UserViewModel(User user) : base(user)
{
}
// Expose model properties with change notification
public string Name
{
get => Model.Name;
set => SetProperty(Model.Name, value, Model, (model, val) => model.Name = val);
}
public string Email
{
get => Model.Email;
set => SetProperty(Model.Email, value, Model, (model, val) => model.Email = val);
}
// Computed properties
public string DisplayName => $"{Model.Name} ({Model.Email})";
// Can access the underlying model directly
public DateTime CreatedAt => Model.CreatedAt;
}
While the library provides the interfaces, here's how you might implement them:
public class RelayCommand<T> : ICommand<T>
{
private readonly Action<T> _execute;
private readonly Func<T, bool> _canExecute;
public event EventHandler CanExecuteChanged;
public RelayCommand(Action<T> execute, Func<T, bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(T parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(T parameter) => _execute(parameter);
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
// Usage in view model
public class ProductViewModel : ObservableObject<Product>
{
public ICommand<Product> SaveCommand { get; }
public ICommand<int> DeleteCommand { get; }
public ProductViewModel(Product product) : base(product)
{
SaveCommand = new RelayCommand<Product>(SaveProduct, CanSaveProduct);
DeleteCommand = new RelayCommand<int>(DeleteProduct);
}
private bool CanSaveProduct(Product product) => !string.IsNullOrEmpty(product?.Name);
private void SaveProduct(Product product) { /* Save logic */ }
private void DeleteProduct(int productId) { /* Delete logic */ }
}
public class AsyncRelayCommand<T> : ICommandAsync<T>
{
private readonly Func<T, Task> _executeAsync;
private readonly Func<T, bool> _canExecute;
public event AsyncEventHandler CanExecuteChanged;
public AsyncRelayCommand(Func<T, Task> executeAsync, Func<T, bool> canExecute = null)
{
_executeAsync = executeAsync ?? throw new ArgumentNullException(nameof(executeAsync));
_canExecute = canExecute;
}
public bool CanExecute(T parameter) => _canExecute?.Invoke(parameter) ?? true;
public async void Execute(T parameter) => await _executeAsync(parameter);
public async Task RaiseCanExecuteChangedAsync()
{
if (CanExecuteChanged != null)
await CanExecuteChanged(this, EventArgs.Empty);
}
}
// Usage in view model
public class DataViewModel : ObservableObject<DataModel>
{
public ICommandAsync<string> LoadDataCommand { get; }
public DataViewModel(DataModel model) : base(model)
{
LoadDataCommand = new AsyncRelayCommand<string>(LoadDataAsync);
}
private async Task LoadDataAsync(string filter)
{
// Async data loading logic
var data = await _dataService.LoadAsync(filter);
Model.Items = data;
OnPropertyChanged(nameof(Model));
}
}
public class UserListViewModel : ObservableObject<IList<User>>
{
public ObservableCollection<UserViewModel> Users { get; }
public ICommand<User> SelectUserCommand { get; }
public ICommandAsync<string> SearchCommand { get; }
public UserListViewModel(IList<User> users) : base(users)
{
Users = new ObservableCollection<UserViewModel>(
users.Select(u => new UserViewModel(u)));
SelectUserCommand = new RelayCommand<User>(SelectUser);
SearchCommand = new AsyncRelayCommand<string>(SearchUsersAsync);
}
private void SelectUser(User user)
{
SelectedUser = Users.FirstOrDefault(vm => vm.Model == user);
}
private async Task SearchUsersAsync(string searchTerm)
{
var filteredUsers = await _userService.SearchAsync(searchTerm);
Model.Clear();
foreach (var user in filteredUsers)
{
Model.Add(user);
Users.Add(new UserViewModel(user));
}
}
[ObservableProperty]
private UserViewModel _selectedUser;
}
public class OrderViewModel : ObservableObject<Order>
{
public CustomerViewModel Customer { get; }
public ObservableCollection<OrderItemViewModel> Items { get; }
public OrderViewModel(Order order) : base(order)
{
Customer = new CustomerViewModel(order.Customer);
Items = new ObservableCollection<OrderItemViewModel>(
order.Items.Select(item => new OrderItemViewModel(item)));
}
public decimal TotalAmount => Items.Sum(item => item.Total);
// Forward property changes from nested view models
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
if (e.PropertyName == nameof(Items))
{
OnPropertyChanged(nameof(TotalAmount));
}
}
}
This library works seamlessly with CommunityToolkit.Mvvm features:
public partial class ProductViewModel : ObservableObject<Product>
{
public ProductViewModel(Product product) : base(product)
{
}
// Use CommunityToolkit.Mvvm source generators
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _statusMessage;
// Relay commands from CommunityToolkit.Mvvm
[RelayCommand]
private async Task SaveAsync()
{
IsLoading = true;
try
{
await _productService.SaveAsync(Model);
StatusMessage = "Product saved successfully";
}
finally
{
IsLoading = false;
}
}
// Strongly-typed commands from this library
public ICommand<Product> ValidateCommand { get; }
}
// ❌ Don't put UI logic in models
public class User
{
public string Name { get; set; }
public bool IsVisible { get; set; } // UI concern
}
// ✅ Keep models focused on business logic
public class User
{
public string Name { get; set; }
public UserRole Role { get; set; }
}
public class UserViewModel : ObservableObject<User>
{
public bool IsVisible => Model.Role != UserRole.Hidden;
}
// ❌ Weak typing requires casting
public ICommand DeleteCommand { get; } // object parameter
// ✅ Strong typing prevents runtime errors
public ICommand<int> DeleteCommand { get; } // int parameter
public class ProductViewModel : ObservableObject<Product>
{
// ✅ Expose with change notification for bindable properties
public string Name
{
get => Model.Name;
set => SetProperty(Model.Name, value, Model, (m, v) => m.Name = v);
}
// ✅ Direct access for read-only properties
public DateTime CreatedAt => Model.CreatedAt;
// ✅ Computed properties based on model state
public bool IsNew => Model.Id == 0;
}