Using MVVM Toolkit for Windows Forms App

Jason Ge
7 min readMay 9, 2024

--

The traditional way to develop Windows Forms app is to put all the logic in the Forms code behind. This would create a tight couple between the UI and the business logic and make the unit testing of the business logic impossible.

A better approach is to use the Model-View-ViewModel (MVVM) design pattern for the WinForms app development.

The MVVM pattern helps cleanly separate an application’s business and presentation logic from its user interface (UI). Maintaining a clean separation between application logic and the UI helps address numerous development issues and makes an application easier to test, maintain, and evolve. It can also significantly improve code re-use opportunities and allows developers and UI designers to collaborate more easily when developing their respective parts of an app.

In the above image, the View and ViewModel are loosely coupled using two way data binding and command. The data binding binds the controls’ properties in the View to the ViewModel’s properties. If user changed the property in the UI, for example, the textbox content, the ViewModel’s property that binds to the textbox’s Text property would be updated accordingly. If we changed the ViewModel’s property, the binging control in the View would also be updated.

MVVM Toolkit

The MVVM Toolkit published by Microsoft is a modern, fast, and modular MVVM library. It can be used in different UI framework.

To use the MVVM Toolkit, you need to add the CommunityToolkit.Mvvm nuget package to your ViewModel project. There are several important class inside MVVM Toolkit.

ObservableObject

The ObservableObject is a base class for objects that are observable by implementing the INotifyPropertyChanged and INotifyPropertyChanging interfaces. The view model should inherit from this class.

INotifyPropertyChanged interface definition:

// Summary: Notifies clients that a property value has changed.
public interface INotifyPropertyChanged
{
// Summary: Occurs when a property value changes.
event PropertyChangedEventHandler? PropertyChanged;
}

public delegate void PropertyChangedEventHandler(object? sender, PropertyChangedEventArgs e);

public class PropertyChangedEventArgs : EventArgs
{
public PropertyChangedEventArgs(string? propertyName);
public virtual string? PropertyName { get; }
}

ObservableProperty Attribute

If you decorate a private field with [ObservableProperty] attribute, the MVVM toolkit would generate a public property for this field. For example, if you have a _amount field of decimal type and you decorate it with [ObservableProperty] attribute, MVVM toolkit would generate following code:

public decimal? Amount
{
get => _amount;
set
{
if (!global::System.Collections.Generic.EqualityComparer<decimal?>.Default.Equals(_amount, value))
{
OnAmountChanging(value);
OnAmountChanging(default, value);
OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.Amount);
_amount = value;
OnAmountChanged(value);
OnAmountChanged(default, value);
OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.Amount);
}
}
}

In the setter of the property, it would call the OnPropertyChanged event handler to notify the binding control a property value has changed.

RelayCommand Attribute

The RelayCommand attribute allows MVVM toolkit generating relay command properties for annotated methods. For example, if you decorate following public method with RelayCommand attribute:

[RelayCommand(CanExecute = nameof(CanPay))]
public async Task PayBiller() { ... }

MVVM toolkit would generate PayBillerCommand inside view model:

partial class BillPaymentViewModel
{
/// <summary>The backing field for <see cref="PayBillerCommand"/>.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.RelayCommandGenerator", "8.2.0.0")]
private global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand? payBillerCommand;
/// <summary>Gets an <see cref="global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommand"/> instance wrapping <see cref="PayBiller"/>.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.RelayCommandGenerator", "8.2.0.0")]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::CommunityToolkit.Mvvm.Input.IAsyncRelayCommand PayBillerCommand => payBillerCommand ??= new global::CommunityToolkit.Mvvm.Input.AsyncRelayCommand(new global::System.Func<global::System.Threading.Tasks.Task>(PayBiller), () => CanPay);
}

Get Started

Let’s use an example to explain how to use the MVVM pattern in Windows Forms development. The code shown here is developed in .NET 8.

Solution structure

This solution is a simple Windows Form that allow user to pay bill to selected biller.

Following is the solution structure:

There are 7 projects in total in this solution. We will explain the purpose of each project:

  • WinFormsUI: this project is the View. It contains only one Windows Form. This form provide a UI for use to choose a biller and pay the bill.
  • WinFormViewModel: This project contains the ViewModel of the View.
  • DomainModel: This project contains the domain related objects. Our ViewModel project (WinFormViewModel) will use the objects in the project.
  • DomainService.Abstractions: This project contains the interface definitions for the domain services.
  • DomainService: This project contains the implementations of domain services. These services would call a web api to perform required business operation.
  • WinFormService: This project contains only one service so far: PopupService. This service is to display a message box when user click the “Pay” button to indicate if the payment is successful or not. Since message box is an Windows Forms UI component, we don’t want to directly call the MessageBox.Show() inside our ViewModel. It would create a tight couple between the ViewModel and the UI framework. Instead, we want to use Dependency Injection to inject the IPopupService into the ViewModel.
  • WinFormService.Abstraction: This project contains the interface of the PopupService. The WinFormViewModel project only reference this interface, not the implementation.

WinFormsUI

This is the main project that contains the view (Form1) and the program.cs. The program.cs will register the required services into DI container and start the Form1.

using DomainService;
using DomainService.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using WinFormsService;
using WinFormsService.Abstractions;

namespace WinFormsMVVM
{
internal static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
var host = CreateHostBuilder().Build();
Application.Run(host.Services.GetRequiredService<Form1>());
}

static IHostBuilder CreateHostBuilder()
{
return Host.CreateDefaultBuilder()
.ConfigureServices((context, services) => {
services.AddSingleton<IPopupService, PopupService>();
services.AddSingleton<IPaymentService, PaymentService>();
services.AddTransient<Form1>();
});
}
}
}

The Form1.cs file contains the data binding logic to View Model:

using DomainService.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using WinFormsService.Abstractions;
using WinformViewModel;

namespace WinFormsUI
{
public partial class Form1 : Form
{
private BillPaymentViewModel _viewModel;
private BindingSource billPaymentVeiwModelBindingSource;

public Form1(IServiceProvider provider)
{
InitializeComponent();
_viewModel = new BillPaymentViewModel(provider.GetRequiredService<IPopupService>(), provider.GetRequiredService<IPaymentService>());
billPaymentVeiwModelBindingSource = new BindingSource();
billPaymentVeiwModelBindingSource.DataSource = _viewModel;
}

private void Form1_Load(object sender, EventArgs e)
{
cboBillers.DataSource = this._viewModel.Billers;
cboBillers.DisplayMember = "Name";
cboBillers.DataBindings.Add(nameof(cboBillers.SelectedIndex), _viewModel, nameof(_viewModel.SelectedBiller), false, DataSourceUpdateMode.OnPropertyChanged);
txtAmount.DataBindings.Add(nameof(txtAmount.Text), billPaymentVeiwModelBindingSource, nameof(_viewModel.Amount), true, DataSourceUpdateMode.OnPropertyChanged, "", "C2");
rdbCash.DataBindings.Add(nameof(rdbCash.Checked), billPaymentVeiwModelBindingSource, nameof(_viewModel.IsCashPayment), false, DataSourceUpdateMode.OnPropertyChanged);
rdbCheque.DataBindings.Add(nameof(rdbCheque.Checked), billPaymentVeiwModelBindingSource, nameof(_viewModel.IsChequePayment), false, DataSourceUpdateMode.OnPropertyChanged);
rdbAccount.DataBindings.Add(nameof(rdbAccount.Checked), billPaymentVeiwModelBindingSource, nameof(_viewModel.IsAccountPayment), false, DataSourceUpdateMode.OnPropertyChanged);
btnPay.DataBindings.Add(nameof(btnPay.Command), billPaymentVeiwModelBindingSource, nameof(_viewModel.PayBillerCommand), true);
btnPay.DataBindings.Add(nameof(btnPay.Enabled), billPaymentVeiwModelBindingSource, nameof(_viewModel.CanPay), true, DataSourceUpdateMode.OnPropertyChanged);
}
}
}

Databinding in Windows Forms is very tricky to get it right. Following are some tips:

To bind a textbox to a numeric type:

  • You have to set the 4th parameter (formattingEnabled) to true. Otherwise, when the textbox loses focus, the content inside the textbox would be wiped out.
  • You have to set the 6th parameter (nullValue) to empty string instead of null. Otherwise, when you delete all the content inside the textbox, it would not trigger the update of the view model. Therefore, the previous entered value would still be there in the view model.

WinFormViewModel

The view model object contains the properties that binds to control’s properties in view and the command bind to button click event.

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DomainModel;
using DomainService.Abstractions;
using WinFormsService.Abstractions;

namespace WinformViewModel
{
public partial class BillPaymentViewModel: ObservableObject
{
private int _selectedBiller;
[ObservableProperty]
private decimal? _amount;
private PaymentSource _source;
private bool _canPay;
private IPopupService _popupService;
private IPaymentService _paymentService;

public BillPaymentViewModel(IPopupService popupService, IPaymentService paymentService)
{
Billers =
[
new Biller() { Id = -1, Name="-- Please select a biller to pay --"},
new Biller() { Id = 1, Name="Rogers"},
new Biller() { Id = 2, Name="Bell Canada"}
];
_selectedBiller = 0;
_popupService = popupService;
_paymentService = paymentService;
}

public bool CanPay
{
get
{
var canPayNow = SelectedBiller > 0 && Amount != null && Source != PaymentSource.None;
if (canPayNow != _canPay)
{
_canPay = canPayNow;
OnPropertyChanged();
}
return _canPay;
}
}

/// <summary>
/// Method that is executed when the command is invoked.
/// </summary>
[RelayCommand(CanExecute = nameof(CanPay))]
public async Task PayBiller()
{
if (await _paymentService.PayBillerAsync(Billers[SelectedBiller].Name, Amount.Value, Source))
{
_popupService.ShowMessage("Payment Successful", $"Paid {string.Format("{0:C2}", Amount)} to biller {Billers[SelectedBiller].Name} with {Source}");
}
else
{
_popupService.ShowError("Payment failed", $"Failed to pay {string.Format("{0:C2}", Amount)} to biller {Billers[SelectedBiller].Name} with {Source}");
}
}

public List<Biller> Billers { get; }

public int SelectedBiller
{
get => _selectedBiller;
set
{
if (_selectedBiller == value)
{
return;
}

_selectedBiller = value;
if (_selectedBiller == 0)
{
Amount = null;
_source = PaymentSource.None;
}

// Notify the UI that the property has changed.
OnPropertyChanged();
}
}

public bool IsCashPayment
{
get { return Source == PaymentSource.Cash; }
set
{
if (value && Source != PaymentSource.Cash)
{
Source = PaymentSource.Cash;
}
}
}

public bool IsChequePayment
{
get { return Source == PaymentSource.Cheque; }
set
{
if (value && Source != PaymentSource.Cheque)
{
Source = PaymentSource.Cheque;
}
}
}

public bool IsAccountPayment
{
get { return Source == PaymentSource.Account; }
set
{
if (value && Source != PaymentSource.Account)
{
Source = PaymentSource.Account;
}
}
}

public PaymentSource Source
{
get { return this._source; }
set
{
if (this._source == value)
return;

this._source = value;
OnPropertyChanged(nameof(IsCashPayment));
OnPropertyChanged(nameof(IsChequePayment));
OnPropertyChanged(nameof(IsAccountPayment));
}
}
}
}

The view model object inherits from ObservableObject.

Also notice that the properties (IsCashPayment, IsChequePayment, IsAccountPayment) that bind to 3 radio buttons (rdbCash, rdbCheque, rdbAccount) do not have OnPropertyChanged() call in their setters. They only set the PaymentSource property value if the radio button is selected and the selection is different than current selection. In the PaymentSource property setter, it will issue OnPropertyChanged() calls for 3 properties (IsCashPayment, IsChequePayment, IsAccountPayment) if the selection has changed.

The reason for this is when a radio button is selected, two events would be issued in sequence:

  1. The previous radio button CheckChanged event with check status as false.
  2. The current radio button CheckChanged event with check status as true.

If you call the OnPropertyChanged() inside the property setter that binds to the radio button Checked property, it would mess up the current selected radio button checked status. So the second event would send false as the Checked status. In the end, it would need click a radio button twice to change the selection.

You can find the source code in github: jason-ge/WinFormsMVVM (github.com)

Happy Coding!

--

--

Jason Ge
Jason Ge

Written by Jason Ge

Software developer with over 20 years experience. Recently focus on Vue/Angular and asp.net core.

No responses yet