In the previous part of the series we introduced the MVVM pattern in its traditional form. As you remember, there are three components, the model, the view, and the view model. The view model delivers data to the view, so whenever a property defined in the view model changes, the view is notified of this change and updates itself. With two-way bindings it works also in the opposite direction. The model, on the other hand, contains the domain classes that are used by the view model and is not required at all if only simple data is supposed to be displayed.
We used the TestPage
as our view and we created the TestViewModel
as the view model. We only defined two properties in the view model and we implemented the INotifyChanged
interface.
One of the properties we defined is a one-liner that depends on the other property, but even so there’s quite a lot of typing. We’re going to have much more properties in our app, so let’s make our lives easier by using an MVVM framework that will do some of the tedious work for us. In this series, we’ll be using the MVVM Toolkit, which is part of the .NET Community Toolkit. It uses source generators to generate optimized C# code that is additive to our own code. This just means that the view model class we create consists of two parts – the code we write ourselves and the code generated by the source generators. As such, the class must be marked as partial
.
So, without further ado, let’s jump right in and install the framework.
Table of Contents
Installing the MVVM Toolkit
To install the MVVM Toolkit, right-click on the project and select Manage NuGet Packages. In the Browse tab search for CommunityToolkit.Mvvm
and install it.
If you open the project file (by double-clicking on the project name), you will see the toolkit there:
<Project Sdk="Microsoft.NET.Sdk">
...
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
</ItemGroup>
...
Naturally, depending on when you install it, you may end up with a different version of the package.
Implementing the MVVM Toolkit
Before we start implementing the MVVM Toolkit, let’s have another look at what the TestViewModel
class as it looks right now:
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace Slugrace.ViewModels;
public class TestViewModel : INotifyPropertyChanged
{
string favoriteColor;
public string FavoriteColor
{
get => favoriteColor;
set
{
if (favoriteColor != value)
{
favoriteColor = value;
OnPropertyChanged();
OnPropertyChanged(nameof(LetterCount));
}
}
}
public int? LetterCount => FavoriteColor?.Length;
public ICommand UseColorCommand { get; private set; }
public event PropertyChangedEventHandler PropertyChanged;
public TestViewModel()
{
UseColorCommand = new Command<string>(UseFixedColor);
}
private void UseFixedColor(string color)
{
FavoriteColor = color;
}
void OnPropertyChanged([CallerMemberName] string propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
We need 13 lines of code to implement the FavoriteColor
property. But not if we use the MVVM Toolkit. Let’s actually implement the framework and see the difference.
So, first let’s mark the class as partial
and make it inherit from ObservableObject
. Also, we don’t need to implement the INotifyPropertyChanged
interface anymore because it will be implemented for us by the ObservableObject
. If you right-click on it and select Go To Definition, you’ll see it implements the interface:
public abstract class ObservableObject : INotifyPropertyChanged, INotifyPropertyChanging
So, here’s the first part of the class:
using CommunityToolkit.Mvvm.ComponentModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace Slugrace.ViewModels;
public partial class TestViewModel : ObservableObject
{
string favoriteColor;
...
Remove the PropertyChangedEventHandler
and the OnPropertyChanged
method. Next, remove the public FavoriteColor
property and add the ObservableProperty
attribute to the private backing field. This will cause the source generators to implement the public property for us. Have a look at the code:
...
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
string favoriteColor;
public int? LetterCount => FavoriteColor?.Length;
...
}
We’re not defining the FavoriteColor
property anymore, and yet, there’s no error in the code below where we define the LetterCount
property, which in turn uses the FavoriteColor
property. This is because the public FavoriteColor
property is generated for us behind the scenes and its name is automatically capitalized, so we can use it as if we had defined it ourselves.
But don’t take my word for it. You can see the generated code.
Go to Dependencies
, select one of the platforms, for instance Android, and under:
Analyzers
-> CommunityToolkit.Mvvm.SourceGenerators
-> CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator
you’ll find the Slugrace.ViewModels.TestViewModel.g.cs
generated file:
Open the file and you will see that the public property is indeed created for us:
// <auto-generated/>
...
namespace Slugrace.ViewModels
{
/// <inheritdoc/>
partial class TestViewModel
{
/// <inheritdoc cref="favoriteColor"/>
[global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "8.2.0.0")]
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public string FavoriteColor
{
...
}
...
So, the public property exists and we can still bind to it in the view. If you run the app, it will still work:
But, as you can see, the length of the string is not displayed anymore, even though we defined the LetterCount
property ourselves. This is because, as you may remember, the original version of the FavoriteColor
property called the OnPropertyChanged
method not only on itself, but also on the LetterCount
property. This is now gone. But don’t worry, we just have to tell the generator to generate this functionality for us. To this end we have to add another attribute to the backing field, NotifyPropertyChangedFor
, and specify the name of the property we want it to work for:
...
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LetterCount))]
string favoriteColor;
public int? LetterCount => FavoriteColor?.Length;
...
Now everything should work as before.
We’re using commands to call methods defined in the view model. These are built-in .NET MAUI commands, but we can use MVVM Toolkit commands instead.
MVVM Toolkit Commands
By using the MVVM Toolkit commands, we can leverage source generators to further reduce our code. We don’t have to create the UseColorCommand
in the view model anymore and we don’t have to instantiate it in the constructor. Instead, we just have to add the RelayCommand
attribute to the method that is supposed to be called when the button is clicked. Now the code should look like this:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Slugrace.ViewModels;
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LetterCount))]
string favoriteColor;
public int? LetterCount => FavoriteColor?.Length;
[RelayCommand]
void UseFixedColor(string color)
{
FavoriteColor = color;
}
}
If you open the file with the generated code (the file ends with RelayCommandGenerator
), you’ll see the command has been generated for us:
// <auto-generated/>
...
namespace Slugrace.ViewModels
{
/// <inheritdoc/>
partial class TestViewModel
{
...
public global::CommunityToolkit.Mvvm.Input.IRelayCommand<string> UseFixedColorCommand => useFixedColorCommand ??= new global::CommunityToolkit.Mvvm.Input.RelayCommand<string>(new global::System.Action<string>(UseFixedColor));
}
}
As you can see, the command is named after the method. We have to use this name, UseFixedColorCommand
, in the view:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
...
<Button
Text="RED"
Command="{Binding UseFixedColorCommand}"
CommandParameter="red" />
<Button
Text="BLUE"
Command="{Binding UseFixedColorCommand}"
CommandParameter="blue" />
<Button
Text="YELLOW"
Command="{Binding UseFixedColorCommand}"
CommandParameter="yellow" />
...
The code in the view model is now much more concise. And, what’s important, the app works just like before. Try it out.
Now, we’ve covered the most important stuff related to the MVVM Toolkit. Naturally, there’s much more to it, but this is enough to start implementing it in our application. We’ll be creating the models and view models progressively, adding properties and command as we proceed and as we need them. And we’ll be modifying the views accordingly. Let’s start by adding some models.
Project Upgrade to .NET 8
Before we start implementing the MVVM pattern in our Slugrace application, let’s upgrade it to the latest version of the framework. It so happened that .NET 8 was just released on November 14th, 2023, so why not use the latest changes and bug fixes? Yes, there is a bug in our app, which you may have noticed when running the app on Windows. In the SettingsPage
there are two groups of radio buttons, so it should be possible to check one radio button in each group. However, if you set the IsChecked
property of one button in each group to True
, only the last radio button will be checked. It works correctly on Android, but our app will be deployed to Windows as well, so it’s worth fixing.
OK, so how do you upgrade your project? First of all, you have to download and install the framework from the official Microsoft website. Then, double click the name of the project (Slugrace) in Visual Studio:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net8.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
Save the file. After a while the dependencies will be updated:
In order for this to work correctly on Android, I also added the following ItemGroup
:
<Project Sdk="Microsoft.NET.Sdk">
...
<ItemGroup>
<...
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="8.0.3" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="8.0.3" />
</ItemGroup>
<ItemGroup>
<MauiXaml Update="Controls\Bets.xaml">
...
That’s it. The app now works correctly on both Windows and Android.
Models in the Slugrace Application
Our application is a 2D game. There are players who play the game and place bets on slugs. So, we’re going to need three model classes: Player
, Slug
and Game
.
The Player
class will contain properties and methods related to the player, so a human being playing the game. Properties like Name
, InitialMoney
, CurrentMoney
, etc. probably come to your mind. A player must also be able to place a specified amount of money on a slug, etc.
The Slug
class will contain properties and methods related to the slug. Each slug needs a name, runs at a specified speed, can win or lose a race.
These are just some examples. In fact, our models are going to need more properties and methods as we proceed, but we’ll be updating the models as we go. For now, let’s create the three classes in the Models
folder and add some basic properties to them.
So, the Player.cs
file looks like this:
namespace Slugrace.Models;
public class Player
{
public int Id { get; set; }
public string Name { get; set; }
public int InitialMoney { get; set; } = 0;
public int CurrentMoney { get; set; }
public int WonOrLostMoney { get; set; }
public int BetAmount { get; set; }
}
For now, we just have a bunch of properties with self-explanatory names.
The Slug.cs
file looks like this:
namespace Slugrace.Models;
public class Slug
{
public string Name { get; set; }
}
Yes, I know, this isn’t much, but don’t worry, we’ll add quite a few properties and methods in this class. All in due time.
And here’s the Game.cs
file:
namespace Slugrace.Models;
public enum EndingCondition
{
Money,
Races,
Time
}
public class Game
{
public List<Player> Players { get; set; }
public List<Slug> Slugs { get; set; }
public EndingCondition GameEndingCondition { get; set; } = EndingCondition.Money;
public int NumberOfRacesSet { get; set; } = 0;
public int GameTimeSet { get; set; } = 0;
}
We’re keeping it simple for now. I defined an enumeration here that will be used only in this class and later in the view model. By default the game is supposed to end when there’s only one player with any money left or when there’s none.
Let’s now move on to views and view models. When we were creating the pages, we included content views in some of them. We’ll create a separate view model for the PlayerSettings
content view, but generally we’ll create a separate view model for each page. Actually, some of the pages will share the same view model. Anyway, let’s start with the PlayerSettings
content view.
PlayerSettings View and View Model
There is one content view embedded in the SettingsPage
, PlayerSettigs
. It represents a single player. There may be between one and four players in the game. There always is at least one. In the SettingsPage
there will always be four instances of PlayerSettings
, but later in the game there will be as many players as we decide.
Anyway, PlayerSettings
will be our view. Let’s now create a view model for the view to bind to. In the ViewModels
folder create a new class and name it PlayerSettingsViewModel
. Here’s the code:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Slugrace.Messages;
using Slugrace.Models;
namespace Slugrace.ViewModels;
public partial class PlayerSettingsViewModel : ObservableObject
{
const int maxNameLength = 10;
const int minInitialMoney = 10;
const int maxInitialMoney = 5000;
private Player player;
public int PlayerId
{
get => player.Id;
set
{
if (player.Id != value)
{
player.Id = value;
OnPropertyChanged();
}
}
}
public string PlayerName
{
get => player.Name;
set
{
if (player.Name != value)
{
player.Name = value;
OnPropertyChanged();
OnPropertyChanged(nameof(NameIsValid));
OnPropertyChanged(nameof(PlayerIsValid));
WeakReferenceMessenger.Default.Send(new PlayerNameChangedMessage(value));
}
}
}
public int PlayerInitialMoney
{
get => player.InitialMoney;
set
{
if (player.InitialMoney != value)
{
player.InitialMoney = value;
OnPropertyChanged();
OnPropertyChanged(nameof(InitialMoneyIsValid));
OnPropertyChanged(nameof(PlayerIsValid));
WeakReferenceMessenger.Default.Send(new PlayerInitialMoneyChangedMessage(value));
}
}
}
public bool PlayerIsInGame
{
get => player.IsInGame;
set
{
if (player.IsInGame != value)
{
player.IsInGame = value;
OnPropertyChanged();
OnPropertyChanged(nameof(PlayerIsValid));
}
}
}
public bool NameIsValid => (PlayerName == null) || (PlayerName?.Length <= maxNameLength);
public bool InitialMoneyIsValid => Helpers.ValueIsInRange(PlayerInitialMoney,
minInitialMoney, maxInitialMoney);
public bool PlayerIsValid
{
get
{
if (PlayerIsInGame)
{
return NameIsValid && InitialMoneyIsValid;
}
else
{
return true;
}
}
}
public PlayerSettingsViewModel()
{
player = new Player();
}
}
This code is pretty straightforward, although there are a few fragments that may seem a little obscure, like when the messages are sent. Don’t worry, we’re going to talk about them in a moment. But, what do we have here?
The Player
model is stored as a private field. We instantiate a Player
object in the constructor and use its properties inside the view model’s properties.
We don’t have any methods here, just a bunch of properties. We also defined some constants that are used by the properties. The PlayerId
property is an integer number. It will be set to 1, 2, 3 or 4 because there are going to be up to four players.
The PlayerName
property, if changed, will also notify of changes in other properties where it’s used. This way, not only all bindings to PlayerName
will be updated, but also bindings to NameIsValid
and PlayerIsValid
. It will also send a message to SettingsViewModel
(which we are going to create soon) to make sure the other view model knows about this change.
PlayerInitialMoney
is very similar. If changed, it will notify of the change, as well as of the changes in two other properties, and it will sent a message to SettingsViewModel
.
PlayerIsInGame
will be used to mark each potential player as taking or not taking part in the game. So, for example, if we decide that only two players should play in the game, the first two players will have this property set to true
, and the other two to false
.
There are two properties used to ensure the PlayerName
and PlayerInitialMoney
properties are valid. We don’t have to set the name. If we don’t, the name will be later set to a generic one like Player 1 or Player 2. If we decide to set the name, it shouldn’t be too long. In the PlayerInitialMoney
property we’re using a method defined in the Helpers
class. It will be also used in other classes in the app. I implemented the Helpers.ValueIsInRange
method like so:
namespace Slugrace;
public static class Helpers
{
...
public static bool ValueIsInRange(int value, int min, int max) => value >= min && value <= max;
}
Finally, the PlayerIsValid
property will be set to true
in two cases: if the player is not in the game (like players 3 and 4 in the example above) and if they are in the game and have a valid name and initial money. This property will be used to make sure the button in the SettingsPage
is enabled only if all players have valid data. This doesn’t have to be true about players who will not take part in the game because they will not be used in the following pages in the app.
Now we can set the view model as the binding context of the view and bind to its properties. Here’s the modified PlayerSettings
view:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
xmlns:behaviors="clr-namespace:Slugrace.Behaviors"
xmlns:converters="clr-namespace:Slugrace.Converters"
x:DataType="viewmodels:PlayerSettingsViewModel"
IsVisible="{Binding PlayerIsInGame}"
x:Class="Slugrace.Controls.PlayerSettings">
<ContentView.BindingContext>
<viewmodels:PlayerSettingsViewModel />
</ContentView.BindingContext>
<ContentView.Resources>
<Style TargetType="Label"
BasedOn="{StaticResource labelBaseStyle}" />
<converters:ZeroToEmptyStringConverter x:Key="zeroToEmptyConverter" />
</ContentView.Resources>
<Grid
RowDefinitions="*"
Margin="0, 10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{OnPlatform 150, Android=80}" />
<ColumnDefinition Width="{OnPlatform 3*, Android=2.5*}" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="2*" />
</Grid.ColumnDefinitions>
<Label Text="{Binding PlayerId, StringFormat='Player {0}'}" />
<Entry
x:Name="nameEntry"
Grid.Column="1"
WidthRequest="{OnPlatform 300, Android=130}"
Text="{Binding PlayerName}"
TextChanged="OnNameTextChanged">
</Entry>
<Label
Grid.Column="2"
Text="$" />
<Entry
x:Name="initialMoneyEntry"
Placeholder="1000"
Grid.Column="3"
WidthRequest="{OnPlatform 250, Android=100}"
Keyboard="Numeric"
Text="{Binding PlayerInitialMoney, Converter={StaticResource zeroToEmptyConverter}}"
TextChanged="OnInitialMoneyTextChanged">
<Entry.Behaviors>
<behaviors:NumericInputBehavior />
</Entry.Behaviors>
</Entry>
</Grid>
</ContentView>
Again, most of this code is straightforward, but there are a couple new things we haven’t discussed so far. We’re going to have a look at them soon. But first things first…
In MVVM we usually tend to leave as little code in the code-behind as possible. Ideally, we should only initialize the component and set the binding context. In our case we don’t have to do the latter, because we set the binding context in XAML. All or most of the logic should go to the view model. But there’s one exception to this rule, which I already mentioned before. We should keep in the code-behind all the logic that is responsible for the visual aspect of the view. Here we want to set different visual states on the entries depending on the text entered, so we’ll leave the two TextChanged
events and handle them in the PlayerSettings.xaml.cs
file:
using Slugrace.ViewModels;
namespace Slugrace.Controls;
public partial class PlayerSettings : ContentView
{
public PlayerSettings()
{
InitializeComponent();
}
private void OnNameTextChanged(object sender, TextChangedEventArgs e)
{
bool nameValid = (BindingContext as PlayerSettingsViewModel).NameIsValid;
GoToNameState(nameValid);
}
private void OnInitialMoneyTextChanged(object sender, TextChangedEventArgs e)
{
if (BindingContext != null)
{
bool initialMoneyValid = (BindingContext as PlayerSettingsViewModel).InitialMoneyIsValid;
Helpers.HandleNumericEntryState(initialMoneyValid, initialMoneyEntry);
}
}
void GoToNameState(bool nameValid)
{
string visualState = nameValid ? "Valid" : "Invalid";
VisualStateManager.GoToState(nameEntry, visualState);
}
}
Now we’re using the properties defined in the view model to set the entries’ visual state. We’re also using a static method, HandleNumericEntryState
, that I defined in the Helpers
class:
...
public static class Helpers
{
...
public static bool ValueIsInRange(...
public static void HandleNumericEntryState(bool testedValueIsValid, Entry entry)
{
string visualState = testedValueIsValid ? "Valid" : "Invalid";
if (entry != null)
{
bool isEmpty = entry.Text == string.Empty;
if (isEmpty)
{
visualState = "Empty";
}
VisualStateManager.GoToState(entry, visualState);
}
}
}
The code-behind file is pretty short and it takes care just of the visual aspect of the view. But there are some interesting new elements in the XAML file. We have a converter and a behavior. You know what converters are, but behaviors are probably not so familiar to you. Anyway, let’s have a look at both.
ZeroToEmptyStringConverter
In some entries we’ll be entering text, like for example in the nameEntry
. In others, like in the initialMoneyEntry
(but not only) we’ll be entering numbers. We want the entry to be empty if the value is zero. So, if you see nothing except the placeholder test in the entry, it means the value is zero, not null
. We’ll need a converter for that. In the Converters
folder add a new class and name it ZeroToEmptyStringConverter
. Here’s the code:
using System.Globalization;
namespace Slugrace.Converters;
internal class ZeroToEmptyStringConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
int enteredNumber = int.Parse(value.ToString());
if (enteredNumber == 0 )
{
return string.Empty;
}
else
{
return enteredNumber.ToString();
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
string enteredValue = value.ToString();
if (string.IsNullOrEmpty(enteredValue))
{
return 0;
}
else
{
bool isNumber = int.TryParse(enteredValue, out int number);
if (isNumber)
{
return number;
}
else
{
enteredValue = enteredValue[..^1];
return enteredValue.Length == 0 ? 0 : int.Parse(enteredValue);
}
}
}
}
The converter is used in a two-way binding, so we must implement both methods. And here’s, again, how the converter is used in the view:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...
xmlns:converters="clr-namespace:Slugrace.Converters"
...
<ContentView.Resources>
...
<converters:ZeroToEmptyStringConverter x:Key="zeroToEmptyConverter" />
</ContentView.Resources>
<Grid
...
<Entry
x:Name="initialMoneyEntry"
...
Text="{Binding PlayerInitialMoney, Converter={StaticResource zeroToEmptyConverter}}"
...
So, we must add the namespace first. Then we add the converter to the content view’s resources and reference it in the binding.
OK, the converter is easy to understand, but what about the NumericInputBahavior
on the initialMoneyEntry
?
Behaviors
Behaviors are a nice feature in .NET MAUI that you can use to extend your control classes. Normally, to extend a class, you inherit from it and add some functionality in the derived class. With behaviors it’s not necessary. When you attach a behavior to a control, the functionality defined in it acts as if it was part of the control itself.
We could implement additional functionality in the code-behind as well. But then it would be coupled with that particular view. As we want it to be reusable, we’ll implement it as a behavior that we can then attach to other controls.
The functionality that we want to add is ensuring that only numeric characters can be entered in the entry. So, if you try to type any other character, like a letter or a special character, this character will not show up.
We wouldn’t have to implement this functionality if our app was deployed only to Android, because we set the Keyboard
property to Numeric
. This will prevent you from entering other characters. But it doesn’t work on Windows, where you can still enter anything.
So, let’s create a new folder and name it Behaviors
. In the folder let’s create a new class and name it NumericInputBehavior
. Here’s the code:
namespace Slugrace.Behaviors;
public class NumericInputBehavior : Behavior<Entry>
{
protected override void OnAttachedTo(Entry bindable)
{
bindable.TextChanged += Bindable_TextChanged;
base.OnAttachedTo(bindable);
}
protected override void OnDetachingFrom(Entry bindable)
{
bindable.TextChanged -= Bindable_TextChanged;
base.OnDetachingFrom(bindable);
}
private void Bindable_TextChanged(object sender, TextChangedEventArgs e)
{
var entry = (Entry)sender;
if (!string.IsNullOrEmpty(e.NewTextValue))
{
bool isNumeric = int.TryParse(e.NewTextValue, out int value);
if (!isNumeric)
{
entry.Text = e.OldTextValue;
}
}
}
}
As you can see, the behavior is meant to be attached to Entry
controls. In particular, it’s added to the TextChanged
event. The implementation is simple. If the last character you entered is not a number, the Text
property of the entry is set to the old text, so the text before you typed that character.
And, once again, let’s have a look at how this behavior is added to the initialMoneyEntry
:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...
xmlns:behaviors="clr-namespace:Slugrace.Behaviors"
...
<Entry
x:Name="initialMoneyEntry"
...
<Entry.Behaviors>
<behaviors:NumericInputBehavior />
</Entry.Behaviors>
</Entry>
...
You won’t be able to type non-numeric characters anymore.
The PlayerSettings
view is embedded in the SettingsPage
. So, let’s take care of this view and create a corresponding view model.
SettingsViewModel
We’ll need a view model for the SettingsPage
(which is a view). Go to the ViewModels
folder and create a new class. Name it SettingsViewModel
.
Let’s start by registering the page and the view model with the dependency service in the MauiProgram
class, just like we did with the TestPage
:
...
public static class MauiProgram
...
builder.Services.AddTransient<TestPage>();
builder.Services.AddTransient<TestViewModel>();
builder.Services.AddTransient<SettingsPage>();
builder.Services.AddTransient<SettingsViewModel>();
return builder.Build();
...
Now we can set the binding context in the code-behind using dependency injection. Here’s the SettingsPage.xaml.cs
file:
using Slugrace.ViewModels;
namespace Slugrace.Views;
public partial class SettingsPage : ContentPage
{
public SettingsPage(SettingsViewModel settingsViewModel)
{
InitializeComponent();
BindingContext = settingsViewModel;
}
}
Now we can implement the SettingsViewModel
class:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Slugrace.Messages;
using Slugrace.Models;
using System.Collections.ObjectModel;
namespace Slugrace.ViewModels;
public partial class SettingsViewModel : ObservableObject
{
const int minRaces = 1;
const int maxRaces = 100;
const int minTime = 1;
const int maxTime = 120;
private Game game;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AllSettingsAreValid))]
private ObservableCollection<PlayerSettingsViewModel> players;
public EndingCondition GameEndingCondition
{
get => game.GameEndingCondition;
set
{
if (game.GameEndingCondition != value)
{
game.GameEndingCondition = value;
OnPropertyChanged();
OnPropertyChanged(nameof(AllSettingsAreValid));
}
}
}
public int NumberOfRacesSet
{
get => game.NumberOfRacesSet;
set
{
if (game.NumberOfRacesSet != value)
{
game.NumberOfRacesSet = value;
OnPropertyChanged();
OnPropertyChanged(nameof(AllSettingsAreValid));
}
}
}
public int GameTimeSet
{
get => game.GameTimeSet;
set
{
if (game.GameTimeSet != value)
{
game.GameTimeSet = value;
OnPropertyChanged();
OnPropertyChanged(nameof(AllSettingsAreValid));
}
}
}
public bool MaxRacesIsValid => Helpers.ValueIsInRange(NumberOfRacesSet,
minRaces, maxRaces);
public bool MaxTimeIsValid => Helpers.ValueIsInRange(GameTimeSet,
minTime, maxTime);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AllSettingsAreValid))]
[NotifyPropertyChangedFor(nameof(OnlyOnePlayer))]
[NotifyPropertyChangedFor(nameof(RacesEndingConditionSet))]
private int currentNumberOfPlayers = 2;
public bool OnlyOnePlayer => CurrentNumberOfPlayers == 1;
public bool RacesEndingConditionSet
=> (CurrentNumberOfPlayers == 1 && GameEndingCondition != EndingCondition.Time)
|| GameEndingCondition == EndingCondition.Races;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AllSettingsAreValid))]
private string changedPlayerName;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AllSettingsAreValid))]
private int? changedPlayerInitialMoney;
public bool AllSettingsAreValid
{
get
{
bool conditionPlayers = Players.All(p => p.PlayerIsValid);
bool conditionMoney = GameEndingCondition == EndingCondition.Money;
bool conditionRaces = GameEndingCondition == EndingCondition.Races && MaxRacesIsValid;
bool conditionTime = GameEndingCondition == EndingCondition.Time && MaxTimeIsValid;
return conditionPlayers && (conditionMoney || conditionRaces || conditionTime);
}
}
public SettingsViewModel()
{
game = new Game();
Players = new ObservableCollection<PlayerSettingsViewModel>
{
new() { PlayerId = 1, PlayerIsInGame = true },
new() { PlayerId = 2, PlayerIsInGame = true },
new() { PlayerId = 3, PlayerIsInGame = false },
new() { PlayerId = 4, PlayerIsInGame = false }
};
WeakReferenceMessenger.Default.Register<PlayerNameChangedMessage>(this, (r, m) =>
OnPlayerNameChangedMessageReceived(m.Value));
WeakReferenceMessenger.Default.Register<PlayerInitialMoneyChangedMessage>(this, (r, m) =>
OnPlayerInitialMoneyChangedMessageReceived(m.Value));
}
private void OnPlayerNameChangedMessageReceived(string value)
{
ChangedPlayerName = value;
}
private void OnPlayerInitialMoneyChangedMessageReceived(int? value)
{
ChangedPlayerInitialMoney = value;
}
[RelayCommand]
void CreatePlayerList(int numberOfPlayers)
{
for (int i = 0; i < Players.Count; i++)
{
Players[i].PlayerIsInGame = i < numberOfPlayers;
}
CurrentNumberOfPlayers = numberOfPlayers;
if (OnlyOnePlayer)
{
GameEndingCondition = EndingCondition.Races;
}
}
[RelayCommand]
void SetEndingCondition(string condition)
{
GameEndingCondition = condition switch
{
"money" => EndingCondition.Money,
"races" => EndingCondition.Races,
"time" => EndingCondition.Time,
_ => EndingCondition.Money
};
}
}
There are a couple constants, a couple observable properties and a couple regular properties. There are a couple methods, too.
At the beginning of the file, we define a private game
variable that we set to a new instance of Game
in the constructor. We also define the observable collection called players
, which we populate in the constructor, too. There are going to be up to four players in the game, so we need four instances of PlayerSettingsViewModel
to allow the user to set the names and initial money of the players. Here, in the constructor, we set the PlayerId
property of each player, which will be used to display the player’s generic name (Player 1
, Player 2
, etc.). We also set the PlayerIsInGame
property of the first two players to true
, and of the other two players to false
. This is because we assume the game is by default for two players, which can be naturally changed in the SettingsPage
.
As we’re at the constructor, we register two messages there. We’ll be talking about the messaging system in a moment. And now let’s have a look at the properties that we have in the view model.
The GameEndingCondition
, NumberOfRacesSet
and GameTimeSet
are properties related to the game itself, so we use the game
object in them.
The MaxRacesIsValid
and MaxTimeIsValid
are responsible for ensuring that the user of the app sets the number of races or the time of the game, depending on which ending condition is chosen, to a value within a certain range.
The currentNumberOfPlayers
observable property is an important one. Look how many change notifications are involved with it. Its value will be set by the radio buttons in the upper part of the SettingsPage
.
The RacesEndingConditionSet
property will be used to ensure that if there is only one player selected and the first ending condition (Money
) is unavailable (because it wouldn’t make sense to end the game when there’s only one player with any money left in this situation), the default ending condition will be set to Races
.
The two observable properties that we can see next, changedPlayerName
and changedPlayerInitialMoney
, are part of the messaging system and will be discussed later.
The last property is AllSettingsAreValid
. There are a couple conditions defined in it to ensure that that everything is set to a valid value in the SettingsPage
. Only if this property is true
, will the Ready button be enabled.
Next, below the constructor, you can see some methods. The first two, OnPlayerNameChangedMessageReceived
and OnPlayerInitialMoneyChangedMessageReceived
are related to the messaging system. Next, we have the CreatePlayerList
method that will handle the players when we check one of the radio buttons. It will set the players’ PlayerIsInGame
property and the CurrentNumberOfPlayers
property. If the one-player game mode is selected and the current ending condition is Money
, it will be changed to Races
, because Money
is unavailable in this mode.
Finally, the SetEndingCondition
method will do exactly what its name suggests.
We have the view model, so let’s take care of the view. The SettingsPage.xaml
file is pretty lengthy, but we’re going to discuss it piece by piece.
SettingsPage View
So, beware! Here comes the SettingsPage
. This is the complete code:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:controls="clr-namespace:Slugrace.Controls"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
xmlns:behaviors="clr-namespace:Slugrace.Behaviors"
xmlns:converters="clr-namespace:Slugrace.Converters"
x:DataType="viewmodels:SettingsViewModel"
x:Class="Slugrace.Views.SettingsPage">
<ContentPage.Resources>
<x:Double x:Key="entryWidth">300</x:Double>
<x:Double x:Key="invisible">0</x:Double>
<converters:ZeroToEmptyStringConverter x:Key="zeroToEmptyConverter" />
<toolkit:InvertedBoolConverter x:Key="invertedBoolConverter" />
</ContentPage.Resources>
<Grid
Margin="{OnPlatform 10, Android=2}">
<Grid.RowDefinitions>
<RowDefinition Height="{OnPlatform 40, Android=30}" />
<RowDefinition Height="2.5*" />
<RowDefinition Height="1.5*" />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<!--the Settings label-->
<Label
Text="Settings"
FontAttributes="{StaticResource emphasized}"
FontSize="{OnPlatform 24, Android=20}" />
<!--the Players panel-->
<Border
Grid.Row="1">
<Grid>
<Image
Source="all_slugs.png"
Aspect="{OnPlatform Fill, Android=AspectFill}"
Opacity=".5"/>
<VerticalStackLayout>
<Label
Style="{StaticResource labelSectionTitleStyle}">
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="The Players" />
<On Platform="Android" Value="How many players?" />
</OnPlatform>
</Label.Text>
</Label>
<HorizontalStackLayout>
<RadioButton
GroupName="players">
<RadioButton.Content>
<Label>
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="1 player" />
<On Platform="Android" Value="1" />
</OnPlatform>
</Label.Text>
</Label>
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding CreatePlayerListCommand}">
<toolkit:EventToCommandBehavior.CommandParameter>
<x:Int32>1</x:Int32>
</toolkit:EventToCommandBehavior.CommandParameter>
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
</RadioButton>
<RadioButton
x:Name="players2"
IsChecked="True"
GroupName="players">
<RadioButton.Content>
<Label>
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="2 players" />
<On Platform="Android" Value="2" />
</OnPlatform>
</Label.Text>
</Label>
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding CreatePlayerListCommand}">
<toolkit:EventToCommandBehavior.CommandParameter>
<x:Int32>2</x:Int32>
</toolkit:EventToCommandBehavior.CommandParameter>
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
</RadioButton>
<RadioButton
x:Name="players3"
GroupName="players">
<RadioButton.Content>
<Label>
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="3 players" />
<On Platform="Android" Value="3" />
</OnPlatform>
</Label.Text>
</Label>
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding CreatePlayerListCommand}">
<toolkit:EventToCommandBehavior.CommandParameter>
<x:Int32>3</x:Int32>
</toolkit:EventToCommandBehavior.CommandParameter>
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
</RadioButton>
<RadioButton
x:Name="players4"
GroupName="players">
<RadioButton.Content>
<Label>
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="4 players" />
<On Platform="Android" Value="4" />
</OnPlatform>
</Label.Text>
</Label>
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding CreatePlayerListCommand}">
<toolkit:EventToCommandBehavior.CommandParameter>
<x:Int32>4</x:Int32>
</toolkit:EventToCommandBehavior.CommandParameter>
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
</RadioButton>
</HorizontalStackLayout>
<Grid
RowDefinitions="*"
ColumnDefinitions="150, 3*, 2*">
<Label
Grid.Column="1"
Style="{StaticResource labelBaseStyle}">
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="Name (max 10 characters)" />
<On Platform="Android" Value="Name" />
</OnPlatform>
</Label.Text>
</Label>
<Label
Grid.Column="2"
Style="{StaticResource labelBaseStyle}">
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="Initial Money ($10 - $5000)" />
<On Platform="Android" Value="Money" />
</OnPlatform>
</Label.Text>
</Label>
</Grid>
<VerticalStackLayout>
<controls:PlayerSettings>
<controls:PlayerSettings.BindingContext>
<Binding Path="Players[0]" />
</controls:PlayerSettings.BindingContext>
</controls:PlayerSettings>
<controls:PlayerSettings>
<controls:PlayerSettings.BindingContext>
<Binding Path="Players[1]" />
</controls:PlayerSettings.BindingContext>
</controls:PlayerSettings>
<controls:PlayerSettings>
<controls:PlayerSettings.BindingContext>
<Binding Path="Players[2]" />
</controls:PlayerSettings.BindingContext>
</controls:PlayerSettings>
<controls:PlayerSettings>
<controls:PlayerSettings.BindingContext>
<Binding Path="Players[3]" />
</controls:PlayerSettings.BindingContext>
</controls:PlayerSettings>
</VerticalStackLayout>
</VerticalStackLayout>
</Grid>
</Border>
<!--the Ending Conditions panel-->
<Border
Grid.Row="2">
<VerticalStackLayout VerticalOptions="{StaticResource defaultLayoutOptions}">
<Label
Text="Ending Conditions"
Style="{StaticResource labelSectionTitleStyle}"
Margin="0, 0, 0, 10"/>
<Grid
RowDefinitions="*, *, *"
ColumnDefinitions="4*, 2*">
<RadioButton
IsChecked="True"
IsVisible="{Binding OnlyOnePlayer, Converter={StaticResource invertedBoolConverter}}"
GroupName="endingConditions">
<RadioButton.Content>
<Label>
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="The game is over when there is only one player with any money left." />
<On Platform="Android" Value="last player left" />
</OnPlatform>
</Label.Text>
</Label>
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding SetEndingConditionCommand}"
CommandParameter="money">
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
</RadioButton>
<RadioButton
Grid.Row="1"
IsChecked="{Binding RacesEndingConditionSet}"
GroupName="endingConditions">
<RadioButton.Content>
<Label>
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="The game is over not later than after a given number of races." />
<On Platform="Android" Value="number of races" />
</OnPlatform>
</Label.Text>
</Label>
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding SetEndingConditionCommand}"
CommandParameter="races">
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Unchecked" />
<VisualState x:Name="Checked">
<VisualState.Setters>
<Setter TargetName="maxRacesEntry" Property="Opacity" Value="1" />
<Setter TargetName="maxRacesEntry" Property="IsEnabled" Value="True" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</RadioButton>
<Entry
x:Name="maxRacesEntry"
Grid.Row="1"
Grid.Column="1"
WidthRequest="{OnPlatform {StaticResource entryWidth}, Android=100}"
HorizontalOptions="Start"
Opacity="{StaticResource invisible}"
IsEnabled="False"
Text="{Binding NumberOfRacesSet, Converter={StaticResource zeroToEmptyConverter}}"
TextChanged="OnMaxRacesTextChanged">
<Entry.Placeholder>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="Set max number of races (1-100)" />
<On Platform="Android" Value="1-100 races" />
</OnPlatform>
</Entry.Placeholder>
<Entry.Behaviors>
<behaviors:NumericInputBehavior />
</Entry.Behaviors>
</Entry>
<RadioButton
Grid.Row="2"
GroupName="endingConditions">
<RadioButton.Content>
<Label>
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="The game is over not later than after the racing time you set has elapsed." />
<On Platform="Android" Value="specified time" />
</OnPlatform>
</Label.Text>
</Label>
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding SetEndingConditionCommand}"
CommandParameter="time">
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Unchecked" />
<VisualState x:Name="Checked">
<VisualState.Setters>
<Setter TargetName="maxTimeEntry" Property="Opacity" Value="1" />
<Setter TargetName="maxTimeEntry" Property="IsEnabled" Value="True" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</RadioButton>
<Entry
x:Name="maxTimeEntry"
Grid.Row="2"
Grid.Column="1"
WidthRequest="{OnPlatform {StaticResource entryWidth}, Android=100}"
HorizontalOptions="Start"
Opacity="{StaticResource invisible}"
IsEnabled="False"
Text="{Binding GameTimeSet, Converter={StaticResource zeroToEmptyConverter}}"
TextChanged="OnMaxTimeTextChanged">
<Entry.Placeholder>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="Set max game time (1-120 min)" />
<On Platform="Android" Value="1-120 min" />
</OnPlatform>
</Entry.Placeholder>
<Entry.Behaviors>
<behaviors:NumericInputBehavior />
</Entry.Behaviors>
</Entry>
</Grid>
</VerticalStackLayout>
</Border>
<!--the Ready button-->
<Button
Grid.Row="3"
Text="Ready"
IsEnabled="{Binding AllSettingsAreValid}">
</Button>
</Grid>
</ContentPage>
And now let’s have a closer look at it.
Let’s start with the PlayerSettings
controls. Look how they’re bound to the particular players defined in ObservableCollection
in the view model. Here’s the first player as an example:
<controls:PlayerSettings>
<controls:PlayerSettings.BindingContext>
<Binding Path="Players[0]" />
</controls:PlayerSettings.BindingContext>
</controls:PlayerSettings>
Next, have a look at the first radio button in the Ending Conditions section. Its IsVisible
property is bound to the OnlyOnePlayer
property defined in the view model:
IsVisible="{Binding OnlyOnePlayer, Converter={StaticResource invertedBoolConverter}}"
We’re using the InvertedBoolConverter
that we get from the Community Toolkit. We added it to the page’s resources:
<ContentPage.Resources>
...
<toolkit:InvertedBoolConverter x:Key="invertedBoolConverter" />
</ContentPage.Resources>
We want the first radio button to be visible only when there are at least two players.
The two entries in the Ending Conditions section should behave like the entries in the PlayerSettings
controls. The input must be valid in order to proceed. That’s why we added the TextChanged
events. In the code-behind, the SettingsPage.xaml.cs
file, they’re implemented like so:
...
public partial class SettingsPage : ContentPage
{
public SettingsPage(SettingsViewModel settingsViewModel)
{
InitializeComponent();
BindingContext = settingsViewModel;
}
private void OnMaxRacesTextChanged(object sender, TextChangedEventArgs e)
{
if (BindingContext != null && maxRacesEntry != null)
{
bool maxRacesValid = (BindingContext as SettingsViewModel).MaxRacesIsValid;
Helpers.HandleNumericEntryState(maxRacesValid, maxRacesEntry);
}
}
private void OnMaxTimeTextChanged(object sender, TextChangedEventArgs e)
{
if (BindingContext != null && maxTimeEntry != null)
{
bool maxTimeValid = (BindingContext as SettingsViewModel).MaxTimeIsValid;
Helpers.HandleNumericEntryState(maxTimeValid, maxTimeEntry);
}
}
}
There’s also an interesting behavior that requires some more explanation.
EventToCommandBehavior
Let’s start with the player radio buttons, so the four radio buttons used to select the number of players in the game. They all look similar. The second button has the IsChecked
property set to True
, because the default number of players is two. Here’s the first button:
<RadioButton
GroupName="players">
<RadioButton.Content>
...
</RadioButton.Content>
<RadioButton.Behaviors>
<toolkit:EventToCommandBehavior
EventName="CheckedChanged"
Command="{Binding CreatePlayerListCommand}">
<toolkit:EventToCommandBehavior.CommandParameter>
<x:Int32>1</x:Int32>
</toolkit:EventToCommandBehavior.CommandParameter>
</toolkit:EventToCommandBehavior>
</RadioButton.Behaviors>
</RadioButton>
We want to implement the method that we’ll use to add and remove players from the Players
observable collection. As you remember, the CreatePlayerList
method has the RelayCommand
attribute, which means it can be used in commanding. In the TestPage
we bound the button’s Command
property to a method, but now we have to bind the radio buttons. The problem is that, unlike buttons, radio buttons don’t support commands. This means we have to stick with events… unless we use the .NET MAUI Community Toolkit and its EventToCommandBehavior
.
Here we have another behavior. Remember the NumericInputBehavior
in the PlayerSettings
view? We defined that behavior ourselves. Here we’re using a behavior defined in the Community Toolkit.
So, let’s do it. First, we have to install a NuGet package. Right-click the project and select Manage NuGet Packages… Search for the CommunityToolkit.Maui
package (A) and install it (B):
When the package is installed, you will see some instructions to follow. We have to initialize the package. Go to the MauiProgram.cs
file, add the appropriate using statement at the top of the file and call the UseMauiCommunityToolkit
extension method on the builder object:
using Microsoft.Extensions.Logging;
using Slugrace.Views;
using Slugrace.ViewModels;
using CommunityToolkit.Maui;
namespace Slugrace;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCommunityToolkit()
.ConfigureFonts(fonts =>
...
Also in the instructions, you can see how to use the toolkit in XAML. You have to add the following namespace:
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
After adding this namespace in the SettingsPage
, we can make use of the EventToCommandBehavior
. It allows us to invoke a command through an event.
We’re passing a parameter to the command, which is the number of players this button is supposed to set.
We also added the EventToCommandBehavior
to the ending condition radio buttons below where the Command
property is set to the SetEndingCondition
method in the view model.
Make sure the toolkit is added to the namespaces:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:controls="clr-namespace:Slugrace.Controls"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
xmlns:behaviors="clr-namespace:Slugrace.Behaviors"
xmlns:converters="clr-namespace:Slugrace.Converters"
x:DataType="viewmodels:SettingsViewModel"
x:Class="Slugrace.Views.SettingsPage">
Here we have just the first radio button, but make sure to set the behavior on all four radio buttons. Just set the CommandParameter
to 1, 2, 3 and 4 respectively.
There’s one more piece of the puzzle we have to talk about, the messaging system. We’re sending and receiving messages between view models.
The Messaging System
Views bind their properties to properties in view models, but sometimes two view models have to communicate with each other. It’s possible to implement a messaging system that sends messages from one part of the application to any other part, even if the two parts are completely decoupled.
In our app, the button in the SettingsPage
must respond to what happens in each particular PlayerSettings
control. More precisely, it has to be disabled if the player’s name or initial money gets invalid. Let’s take the player’s name as an example. In this property’s setter a couple notifications are sent so that other properties can be updated if necessary. Here’s the PlayerSettingsViewModel.cs
file:
...
public partial class PlayerSettingsViewModel : ObservableObject
{
...
public string PlayerName
{
get => player.Name;
set
{
if (player.Name != value)
{
player.Name = value;
OnPropertyChanged();
OnPropertyChanged(nameof(NameIsValid));
OnPropertyChanged(nameof(PlayerIsValid));
...
But the button in the SettingsPage
also needs to be notified in order to adjust its IsEnabled
property. We can bind this property to a property in the SettingsViewModel
, but not to a property in the PlayerSettingsViewModel
. Fortunately, a property in one view model can send a message that will be received by another view model and a property in the other view model, to which the button can bind, will be set appropriately. Looks a bit complicated, so let’s analyze it step by step.
The first step is to create the message. We’re going to need two messages: one will be sent by the PlayerName
property when it changes, the other by the PlayerInitialMoney
when it changes, so let’s create a folder in the root of our app and name it Messages
. In the folder let’s create two classes and name them PlayerNameChangedMessage
and PlayerInitialMoneyChangedMessage
. Here’s how the former is implemented:
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Slugrace.Messages;
public class PlayerNameChangedMessage : ValueChangedMessage<string>
{
public PlayerNameChangedMessage(string value) : base(value)
{
}
}
The other one looks almost the same:
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Slugrace.Messages;
public class PlayerInitialMoneyChangedMessage : ValueChangedMessage<int?>
{
public PlayerInitialMoneyChangedMessage(int? value) : base(value) { }
}
The MVVM Toolkit supports two types of messengers. The one we’re interested in is WeakReferenceMessenger
. As the name suggests, it uses weak references internally. It offers automatic memory management for the recipients, so you don’t have to remember to unsubscribe the recipients.
Now, with our messages created, the two properties of the PlayerSettingsViewModel
class can send them:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Slugrace.Messages;
using Slugrace.Models;
namespace Slugrace.ViewModels;
public partial class PlayerSettingsViewModel : ObservableObject
{
...
public string PlayerName
{
get => player.Name;
set
{
if (player.Name != value)
{
...
OnPropertyChanged(nameof(PlayerIsValid));
WeakReferenceMessenger.Default.Send(new PlayerNameChangedMessage(value));
}
}
}
public int PlayerInitialMoney
{
get => player.InitialMoney;
set
{
if (player.InitialMoney != value)
{
...
OnPropertyChanged(nameof(PlayerIsValid));
WeakReferenceMessenger.Default.Send(new PlayerInitialMoneyChangedMessage(value));
}
}
}
...
Sending is one thing. Another thing is receiving. Which object is supposed to receive the messages? Well, definitely the SettingsViewModel
. The message must be registered there and, naturally, handled. So, let’s open the SettingsViewModel.cs
file and register the messages in the constructor:
...
public partial class SettingsViewModel : ObservableObject
...
public SettingsViewModel()
{
...
Players = new ObservableCollection<PlayerSettingsViewModel>
...
WeakReferenceMessenger.Default.Register<PlayerNameChangedMessage>(this, (r, m) =>
OnPlayerNameChangedMessageReceived(m.Value));
WeakReferenceMessenger.Default.Register<PlayerInitialMoneyChangedMessage>(this, (r, m) =>
OnPlayerInitialMoneyChangedMessageReceived(m.Value));
}
...
As you can see, there are two methods that will handle the messages. Here’s how they are implemented:
...
public partial class SettingsViewModel : ObservableObject
...
public SettingsViewModel()
{
...
}
private void OnPlayerNameChangedMessageReceived(string value)
{
ChangedPlayerName = value;
}
private void OnPlayerInitialMoneyChangedMessageReceived(int? value)
{
ChangedPlayerInitialMoney = value;
}
[RelayCommand]
void CreatePlayerList(int numberOfPlayers)
...
These two methods set the ChangedPlayerName
and ChangedPlayerInitialMoney
properties respectively to the value passed by the message.
Let’s have a look at how these properties are implemented:
...
public partial class SettingsViewModel : ObservableObject
...
public bool RacesEndingConditionSet
...
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AllSettingsAreValid))]
private string changedPlayerName;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AllSettingsAreValid))]
private int? changedPlayerInitialMoney;
public bool AllSettingsAreValid
...
As you can see, whenever one of these properties changes, the AllSettingsAreValid
property is notified and this is the property the button’s IsEnabled
property binds to. The button can now react to any changes in the properties that sent the messages from a different object.
Now the app works as expected. For example, the button gets disabled when the initial money of one of the players is invalid:
It’s also disabled if you choose the Races ending condition but don’t specify the number of races. This time let’s check it out on Android:
OK, we’ve spent so much time trying to enable or disable the button, depending on the data delivered in the entries, that we didn’t even have time to think about what this button is for in the first place…
And the button is for navigation. If you click it, it will navigate to the RacePage
where the actual game begins. Navigation is going to be the subject of the next part of the series. And don’t worry we only implemented a couple view models. We’re going to implement the other view models in the next part too.