Friday, January 20, 2023

Weak Messaging in WPF

I was watching James Montemagno's YouTube video on weak reference managers and I wondered if it would work in .Net Framework. So I created this WPF, .Net Framework version of his project.

Start a new WPF .Net Framework Visual Studio project using C# and call it Messaging. Add another WPF Window called DetailWindow. Use the GitHub solution manager to add the CommunityToolkit.Mvvm package by Microsoft. I'm using version 8.1.

The finished application will let you create a task list, double-click on a task to see a detail window, and delete the task from the detail window. The detail window does not delete the task - it sends a message to the main window to do it. That's the interesting bit. 

Let's start with the main window's XAML and code behind, without the messaging goodness.

<Window x:Class="Messaging.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:Messaging"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="AddCommand"/>
        <RoutedCommand x:Key="DetailCommand"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource AddCommand}" CanExecute="Add_CanExecute" Executed="Add_Executed"/>
        <CommandBinding Command="{StaticResource DetailCommand}" CanExecute="Detail_CanExecute" Executed="Detail_Executed"/>
    </Window.CommandBindings>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="auto"/>
        </Grid.ColumnDefinitions>
 
        <Border Grid.Row="0" Grid.Column="0" BorderBrush="Black" BorderThickness="2" CornerRadius="5" HorizontalAlignment="Stretch" Margin="5">
            <TextBox Text="{Binding TextToAdd, UpdateSourceTrigger=PropertyChanged}"/>
        </Border>
        <Button Grid.Row="0" Grid.Column="1" Content="Add" Command="{StaticResource AddCommand}" Margin="10,5,5,5"  Width="100" Height="22"/>
 
        <DataGrid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" AutoGenerateColumns="False" HeadersVisibility="None"
                  BorderThickness="0,2,0,0" IsReadOnly="True"
                  ItemsSource="{Binding Tasks}"
                  SelectedItem="{Binding SelectedTask}">
            <DataGrid.InputBindings>
                <MouseBinding Gesture="LeftDoubleClick" Command="{StaticResource DetailCommand}"/>
            </DataGrid.InputBindings>
            <DataGrid.Columns>
                <DataGridTextColumn Binding="{Binding Text}" Width="*"/>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

-----------------------------------------

using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
 
namespace Messaging
{
    public class cTask
    {
        public String Text { get; set; }
    }
 
    public partial class MainWindow : Window, INotifyPropertyChanged, IRecipient<DeleteItemMessage>
    {
        private ObservableCollection<cTask> tasks = new ObservableCollection<cTask>();
        public ObservableCollection<cTask> Tasks
        {
            get { return tasks; }
            set { SetProperty(ref tasks, value); }
        }
 
        private cTask selectedTask = null;
        public cTask SelectedTask
        {
            get { return selectedTask; }
            set { SetProperty(ref selectedTask, value);}
        }
 
        private String textToAdd = "";
        public String TextToAdd
        {
            get { return textToAdd; }
            set { SetProperty(ref textToAdd, value);}
        }
 
        public MainWindow()
        {
            InitializeComponent();
        }
 
        private void Add_CanExecute(object sender, CanExecuteRoutedEventArgs e)
        {
            e.CanExecute = (!string.IsNullOrEmpty(textToAdd));
        }
 
        private void Add_Executed(object sender, ExecutedRoutedEventArgs e)
        {
            if (!string.IsNullOrEmpty(TextToAdd))
            {
                tasks.Add(new cTask() { Text = textToAdd });
                TextToAdd = "";
            }
        }
 
        private void Detail_CanExecute(object sender, CanExecuteRoutedEventArgs e)
        {
            e.CanExecute = (selectedTask != null);
        }
 
        private void Detail_Executed(object sender, ExecutedRoutedEventArgs e)
        {
            DetailWindow detailWindow = new DetailWindow(SelectedTask);
            detailWindow.ShowDialog();
        }
 
        public event PropertyChangedEventHandler PropertyChanged;
        public void SetProperty<T>(ref T storage, T value, [CallerMemberName] string name = "")
        {
            if (!Object.Equals(storage, value))
            {
                storage = value;
                if (PropertyChanged != null)
                    PropertyChanged(this, new PropertyChangedEventArgs(name));
            }
        }
    }
}

Now let's do the same for the detail window.

<Window x:Class="Messaging.DetailWindow"
        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:Messaging"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="Detail Window" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="DeleteCommand"/>
        <RoutedCommand x:Key="CloseCommand"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource DeleteCommand}" Executed="Delete_Executed"/>
        <CommandBinding Command="{StaticResource CloseCommand}" Executed="Close_Executed"/>
    </Window.CommandBindings>
    <StackPanel Orientation="Vertical">
        <Border BorderThickness="2" BorderBrush="DarkBlue" CornerRadius="5" Padding="5">
            <TextBlock Text="{Binding Task.Text}"/>
        </Border>
        <Button Content="Delete" Background="Red" Command="{StaticResource DeleteCommand}"/>
        <Button Content="Close" Command="{StaticResource CloseCommand}"/>
    </StackPanel>
</Window>

----------------------------------------------------

using CommunityToolkit.Mvvm.Messaging;
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
 
namespace Messaging
{
    public partial class DetailWindow : Window, INotifyPropertyChanged
    {
        private cTask task;
        public cTask Task
        {
            get { return task; }
            set { SetProperty(ref task, value); }
        }
 
        public DetailWindow(cTask task)
        {
            InitializeComponent();
            this.Task= task;
        }
 
        private void Delete_Executed(object sender, ExecutedRoutedEventArgs e)
        {
        }
 
        private void Close_Executed(object sender, ExecutedRoutedEventArgs e)
        {
            DialogResult = false;
        }
 
        public event PropertyChangedEventHandler PropertyChanged;
        public void SetProperty<T>(ref T storage, T value, [CallerMemberName] string name = "")
        {
            if (!Object.Equals(storage, value))
            {
                storage = value;
                if (PropertyChanged != null)
                    PropertyChanged(this, new PropertyChangedEventArgs(name));
            }
        }
    }
}

If you run this now you can add tasks to the list and double-click a task to see the detail. The only functionality not implemented is the delete button.


To send the message, we need to define the message type. In MainWindow add a new class after cTask.

public class DeleteItemMessage : ValueChangedMessage<cTask>
{
    public DeleteItemMessage(cTask value) : base(value)
    {
    }
}

Now we have defined our message we can send and receive it. To send it, add the send command to the Delete_Executed event handler in DetailWindow.

private void Delete_Executed(object sender, ExecutedRoutedEventArgs e)
{
    WeakReferenceMessenger.Default.Send(new DeleteItemMessage(task));
    DialogResult = true;
}

There are several ways we can register MainWindow to receive the message. The cleanest is to split the registration and the receiving code.

Add an interface to the MainWindow class declaration

public partial class MainWindow : Window, INotifyPropertyChanged, IRecipient<DeleteItemMessage>


Register to receive specific messages in the constructor (or wherever)

public MainWindow()
{
    InitializeComponent();
    WeakReferenceMessenger.Default.Register<DeleteItemMessage>(this);
}

and write the Receive method required by the interface.

public void Receive(DeleteItemMessage message)
{
    Tasks.Remove(message.Value);
}

Clicking the Delete button now sends a message which is received by MainWindow and causes the Receive method to execute, removing the task from the task list.

If there are multiple registered receivers they are called in most-recently-registered first order. A Receive method can modify the value of the message and those changes will be seen by later Receive methods. So you could create a mechanism to detect which Receive method(s) have already seen the message.

The advantage of using messages instead of getting a reference to the main window and calling a method is that it decouples the main window from the detail window. Another advantage is that a parent window can register to receive messages that are sent from a grandchild window without any code in the child window to bind them together. Message processing is synchronous which means each receive will complete before the next one starts and they all complete before the code after the send is executed.