This article presents a MVVM implementation for Blazor that uses the CommunityToolkit.Mvvm via the library called Blazing.MVVM.
Table of Contents
Overview
MVVM is not required for developing Blazor apps. The binding system is simpler than other application frameworks like WinForms and WPF.
However, the MVVM pattern has many benefits, like separation of logic from the view, testability. reduced risk and collaboration.
There are several libraries out there for Blazor that try to support the MVVM design pattern, and are not the simplest to use. At the same time, there is the CommunityToolkit.Mvvm that supports WPF, Xamarin and MAUI application frameworks.
Why not Blazor? This article presents a MVVM implementation for Blazor that uses the CommunityToolkit.Mvvm via the library called Blazing.MVVM. If you are familiar with the CommunityToolkit.Mvvm, then you already know how to use this implementation.
Downloads
Source code (via GitHub)
** If you find this library useful, please give the Github repo a star.
Nuget:
Part 1 - Blazing.Mvvm Library
This is an expansion of the blazor-mvvm repo by Kelly Adams that implements full MVVM support via the CommunityToolkit.Mvvm. Minor changes were made to prevent cross-thread exceptions, added extra base class types, Mvvm-style navigation, and converted into a usable library.
Getting Started
-
Add the Blazing.Mvvm Nuget package to your project.
-
Enable MvvmNavigation
support in your Program.cs file:
-
Blazor Server App:
builder.Services.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.Server;
});
-
Blazor WebAssembly App:
builder.Services.AddMvvmNavigation();
-
Blazor Web App (new to .NET 8.0)
builder.Services.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.WebApp;
});
-
Blazor Hybrid App (WinForm, WPF, Avalonia, MAUI):
builder.Services.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.Hybrid;
});
- Create a
ViewModel
inheriting the ViewModelBase
class:
public partial class FetchDataViewModel : ViewModelBase
{
[ObservableProperty]
private ObservableCollection<WeatherForecast> _weatherForecasts = new();
public override async Task Loaded()
=> WeatherForecasts = new ObservableCollection<WeatherForecast>(Get());
private static readonly string[] Summaries =
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm",
"Balmy", "Hot", "Sweltering", "Scorching"
};
public IEnumerable<WeatherForecast> Get()
=> Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
- Register the
ViewModel
in your Program.cs file:
builder.Services.AddTransient<FetchDataViewModel>();
- Create your page inheriting the
MvvmComponentBase<TViewModel>
component:
@page "/fetchdata"
@inherits MvvmComponentBase<FetchDataViewModel>
<PageTitle>Weather forecast</PageTitle>
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (!ViewModel.WeatherForecasts.Any())
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in ViewModel.WeatherForecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
- Optionally, modify the
NavMenu.razor
to use MvvmNavLink
for Navigation by ViewModel
:
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=FetchDataViewModel>
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</MvvmNavLink>
</div>
Now run the app.
Navigating by ViewModel
using the MvvmNavigationManager
from code, inject the class into your page or ViewModel
, then use the NavigateTo
method:
mvvmNavigationManager.NavigateTo<FetchDataViewModel>();
The NavigateTo
method works the same as the standard Blazor NavigationManager
and also supports passing of a Relative URL &/or QueryString.
If you are into abstraction, then you can also navigate by interface:
mvvmNavigationManager.NavigateTo<ITestNavigationViewModel>();
The same principle works with the MvvmNavLink
component:
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
RelativeUri="this is a MvvmNavLink test"
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test + Params
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
RelativeUri="?test=this%20is%20a%20MvvmNavLink%20querystring%20test"
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test + QueryString
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
RelativeUri="this is a MvvmNvLink test/?
test=this%20is%20a%20MvvmNavLink%20querystring%20test"
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test + Both
</MvvmNavLink>
</div>
How MVVM Works
There are two parts:
- The
ViewModelBase
- The
MvvmComponentBase
The MvvmComponentBase
handles wiring up the ViewModel
to the component.
public abstract class MvvmComponentBase<TViewModel>
: ComponentBase, IView<TViewModel>
where TViewModel : IViewModelBase
{
[Inject]
protected TViewModel? ViewModel { get; set; }
protected override void OnInitialized()
{
ViewModel!.PropertyChanged += (_, _) => InvokeAsync(StateHasChanged);
base.OnInitialized();
}
protected override Task OnInitializedAsync()
=> ViewModel!.OnInitializedAsync();
}
And here is the ViewModelBase
class that wraps the ObservableObject
:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Blazing.Mvvm.ComponentModel;
public abstract partial class ViewModelBase : ObservableObject, IViewModelBase
{
public virtual async Task OnInitializedAsync()
=> await Loaded().ConfigureAwait(true);
protected virtual void NotifyStateChanged() => OnPropertyChanged((string?)null);
[RelayCommand]
public virtual async Task Loaded()
=> await Task.CompletedTask.ConfigureAwait(false);
}
As the MvvmComponentBase
is listening for PropertyChanged
events from the ViewModelBase
implementation, the MvvmComponentBase
automatically handles refreshing of the UI when properties are changed on the ViewModelBase
implementation or the NotifyStateChanged
is called.
EditForm
Validation and Messaging are also supported. See the sample code for examples of how to use for most use cases.
How the MVVM Navigation Works
No more magic strings! Strongly-typed navigation is now possible. If the page URI changes, there is no more need to go hunting through your source code to make changes. It is auto-magically resolved at runtime for you!
MvvmNavigationManager Class
When the MvvmNavigationManager
is initialized by the IOC container as a Singleton, the class will examine all assemblies and internally caches all ViewModel
s (classes and interfaces) and the page it is associated with. Then when it comes time to navigate, a quick lookup is done and the Blazor NavigationManager
is then used to navigate to the correct page. If any Relative Uri &/or QueryString
was passed in via the NavigateTo
method call, that is passed too.
Note: The MvvmNavigationManager
class is not a total replacement for the Blazor NavigationManager
class, only support for MVVM is implemented. For the standard "magic strings" navigation, use the NavigationManager
class.
public class MvvmNavigationManager : IMvvmNavigationManager
{
private readonly NavigationManager _navigationManager;
private readonly ILogger<MvvmNavigationManager> _logger;
private readonly Dictionary<Type, string> _references = new();
public MvvmNavigationManager(NavigationManager navigationManager,
ILogger<MvvmNavigationManager> logger)
{
_navigationManager = navigationManager;
_logger = logger;
GenerateReferenceCache();
}
public void NavigateTo<TViewModel>(bool? forceLoad = false, bool? replace = false)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
}
public void NavigateTo<TViewModel>(NavigationOptions options)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, options);
}
public void NavigateTo<TViewModel>(string? relativeUri = null,
bool? forceLoad = false, bool? replace = false)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
}
public void NavigateTo<TViewModel>(string relativeUri, NavigationOptions options)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, options);
}
public string GetUri<TViewModel>()
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
return uri;
}
#region Internals
private static string BuildUri(string uri, string? relativeUri)
{
if (string.IsNullOrWhiteSpace(relativeUri))
return uri;
UriBuilder builder = new(uri);
if (relativeUri.StartsWith('?'))
builder.Query = relativeUri.TrimStart('?');
else if (relativeUri.Contains('?'))
{
string[] parts = relativeUri.Split('?');
builder.Path = builder.Path.TrimEnd('/') + "/" + parts[0].TrimStart('/');
builder.Query = parts[1];
}
else
builder.Path = builder.Path.TrimEnd('/') + "/" + relativeUri.TrimStart('/');
return builder.ToString();
}
private void GenerateReferenceCache()
{
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Starting generation of a new Reference Cache");
foreach (Assembly assembly in assemblies)
{
List<(Type Type, Type? Argument)> items;
try
{
items = assembly
.GetTypes()
.Select(GetViewArgumentType)
.Where(t => t.Argument is not null)
.ToList();
}
catch (Exception)
{
continue;
}
if (!items.Any())
continue;
foreach ((Type Type, Type? Argument) item in items)
{
Attribute? attribute = item.Type.GetCustomAttributes()
.FirstOrDefault(a => a is RouteAttribute);
if (attribute is null)
continue;
string uri = ((RouteAttribute)attribute).Template;
_references.Add(item.Argument!, uri);
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Caching navigation reference
'{item.Argument!}' with
uri '{uri}' for '{item.Type.FullName}'");
}
}
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Completed generating the Reference Cache");
}
private static (Type Type, Type? Argument) GetViewArgumentType(Type type)
{
Type viewInterfaceType = typeof(IView<>);
Type viewModelType = typeof(IViewModelBase);
Type ComponentBaseGenericType = typeof(MvvmComponentBase<>);
Type? ComponentBaseType = null;
Type? typeArgument = null;
foreach (Type interfaceType in type.GetInterfaces())
{
if (!interfaceType.IsGenericType ||
interfaceType.GetGenericTypeDefinition() != viewInterfaceType)
continue;
typeArgument = interfaceType.GetGenericArguments()[0];
ComponentBaseType = ComponentBaseGenericType.MakeGenericType(typeArgument);
break;
}
if (ComponentBaseType == null)
return default;
if (!ComponentBaseType.IsAssignableFrom(type))
return default;
Type[] interfaces = ComponentBaseType
.GetGenericArguments()[0]
.GetInterfaces();
if (interfaces.FirstOrDefault(i => i.Name == $"{viewModelType.Name}") is null)
return default;
return (type, typeArgument);
}
#endregion
}
Note: If you enable Debug
level logging, MvvmNavigationManager
will output the associations made when building the cache. For example:
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Starting generation of a new Reference Cache
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.FetchDataViewModel'
with uri '/fetchdata' for 'Blazing.Mvvm.Sample.Wasm.Pages.FetchData'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.EditContactViewModel'
with uri '/form' for 'Blazing.Mvvm.Sample.Wasm.Pages.Form'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.HexTranslateViewModel'
with uri '/hextranslate' for 'Blazing.Mvvm.Sample.Wasm.Pages.HexTranslate'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.ITestNavigationViewModel'
with uri '/test' for 'Blazing.Mvvm.Sample.Wasm.Pages.TestNavigation'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Completed generating the Reference Cache
MvvmNavLink Component
The MvvmNavLink
component is based on the Blazor Navlink
component and has an extra TViewModel
and RelativeUri
properties. Internally, uses the MvvmNavigationManager
to do the navigation.
public class MvvmNavLink<TViewModel> : ComponentBase, IDisposable
where TViewModel : IViewModelBase
{
private const string DefaultActiveClass = "active";
private bool _isActive;
private string? _hrefAbsolute;
private string? _class;
[Inject]
private IMvvmNavigationManager MvvmNavigationManager { get; set; } = default!;
[Inject]
private NavigationManager NavigationManager { get; set; } = default!;
[Parameter]
public string? ActiveClass { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? AdditionalAttributes { get; set; }
protected string? CssClass { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public NavLinkMatch Match { get; set; }
[Parameter]
public string? RelativeUri { get; set; }
protected override void OnInitialized()
{
NavigationManager.LocationChanged += OnLocationChanged;
}
protected override void OnParametersSet()
{
_hrefAbsolute = BuildUri(NavigationManager.ToAbsoluteUri(
MvvmNavigationManager.GetUri<TViewModel>()).AbsoluteUri, RelativeUri);
AdditionalAttributes?.Add("href", _hrefAbsolute);
_isActive = ShouldMatch(NavigationManager.Uri);
_class = null;
if (AdditionalAttributes != null &&
AdditionalAttributes.TryGetValue("class", out object? obj))
_class = Convert.ToString(obj, CultureInfo.InvariantCulture);
UpdateCssClass();
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
private static string BuildUri(string uri, string? relativeUri)
{
if (string.IsNullOrWhiteSpace(relativeUri))
return uri;
UriBuilder builder = new(uri);
if (relativeUri.StartsWith('?'))
builder.Query = relativeUri.TrimStart('?');
else if (relativeUri.Contains('?'))
{
string[] parts = relativeUri.Split('?');
builder.Path = builder.Path.TrimEnd('/') + "/" + parts[0].TrimStart('/');
builder.Query = parts[1];
}
else
builder.Path = builder.Path.TrimEnd('/') + "/" + relativeUri.TrimStart('/');
return builder.ToString();
}
private void UpdateCssClass()
=> CssClass = _isActive
? CombineWithSpace(_class, ActiveClass ?? DefaultActiveClass)
: _class;
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
bool shouldBeActiveNow = ShouldMatch(args.Location);
if (shouldBeActiveNow != _isActive)
{
_isActive = shouldBeActiveNow;
UpdateCssClass();
StateHasChanged();
}
}
private bool ShouldMatch(string currentUriAbsolute)
{
if (_hrefAbsolute == null)
return false;
if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute))
return true;
return Match == NavLinkMatch.Prefix
&& IsStrictlyPrefixWithSeparator(currentUriAbsolute, _hrefAbsolute);
}
private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)
{
Debug.Assert(_hrefAbsolute != null);
if (string.Equals(currentUriAbsolute, _hrefAbsolute,
StringComparison.OrdinalIgnoreCase))
return true;
if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1)
{
if (_hrefAbsolute[^1] == '/'
&& _hrefAbsolute.StartsWith(currentUriAbsolute,
StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "a");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
builder.AddContent(3, ChildContent);
builder.CloseElement();
}
private static string CombineWithSpace(string? str1, string str2)
=> str1 == null ? str2 : $"{str1} {str2}";
private static bool IsStrictlyPrefixWithSeparator(string value, string prefix)
{
int prefixLength = prefix.Length;
if (value.Length > prefixLength)
{
return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& (
prefixLength == 0
|| !char.IsLetterOrDigit(prefix[prefixLength - 1])
|| !char.IsLetterOrDigit(value[prefixLength])
);
}
return false;
}
}
Tests
Tests are included for the navigation and messaging.
Part 2 - Converting an Existing Application
Whilst the repo contains a basic sample project showing how to use the library, I wanted to include a sample that takes an existing project for a different application type and, with minimal changes, make it work for Blazor. So I have taken Microsoft's Xamarin Sample Project and converted it to Blazor.
Changes made to Xamarin Sample for Blazor
The MvvmSample.Core
project remains mostly unchanged, I've added base classes to the ViewModel
s to enable Blazor binding updates.
So, as an example, the SamplePageViewModel
was changed from:
public class MyPageViewModel : ObservableObject
{
}
to:
public class MyPageViewModel : ViewModelBase
{
}
The ViewModelBase
wraps the ObservableObject
class. No other changes required.
For the Xamarin Pages, wiring up the DataContext
is done with:
BindingContext = Ioc.Default.GetRequiredService<MyPageViewModel>();
With Blazing.MVVM, it is simply:
@inherits MvvmComponentBase<MyPageViewModel>
Lastly, I have updated all of the documentation used in the Sample application from Xamarin-specific to Blazor. If I have missed any changes, please let me know and I will update.
Components
Xamarin comes with a rich set of controls. Blazor is lean in comparison. To keep this project lean, I have included my own ListBox
and Tab
controls - enjoy! When I have time, I will endeavor to complete and release a control library for Blazor.
WASM + New WPF & Avalonia Blazor Hybrid Sample Applications
I have added new WPF/Avalonia Hybrid apps to demonstrate Calling into Blazor from WPF/Avalonia using MVVM. To do this, I have:
- Moved the core shared parts from the
BlazorSample
app to a new RCL (Razor Class Library) - Moved the Assets to a standard Content folder as the wwwroot is no longer accessible. The
BlazorWebView
host control uses ip address 0.0.0.0
which is invalid for the httpClient
. - Added new
FileService
class to the WPF/Avalonia app to use the File
class and not the HttpClient
. - Added a new
App.Razor
to the WPF/Avalonia app for custom Blazor layout and hook the shared state for handling navigation requests from WPF/Avalonia. - To enable the calling into the Blazor app, I have used a
static
state class to hold a reference to the NavigationManager
and MvvvmNavigationManager
classes.
Blazor Wasm Sample App
As we have moved the core of the Blazor app to a shared project MvvmSampleBlazor.Core
, we only need to add a reference.
Program.cs
We need to bootstrap and bind the app together:
WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services
.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) })
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServices()
.AddMvvmNavigation();
#if DEBUG
builder.Logging.SetMinimumLevel(LogLevel.Trace);
#endif
await builder.Build().RunAsync();
App.razor
Next, we need to point to the location of the pages in the app.razor
:
<Router AppAssembly="@typeof(MvvmSampleBlazor.Core.Root).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
Lastly, we will wire up the blazor navigation:
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
@*<a class="navbar-brand" href="">Blazor Mvvm Sample</a>*@
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<i class="bi bi-play" aria-hidden="true"></i> Introduction
</NavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=ObservableObjectPageViewModel>
<i class="bi bi-arrow-down-up" aria-hidden="true"></i> ObservableObject
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=RelayCommandPageViewModel>
<i class="bi bi-layer-backward" aria-hidden="true"></i> Relay Commands
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=AsyncRelayCommandPageViewModel>
<i class="bi bi-flag" aria-hidden="true"></i> Async Commands
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=MessengerPageViewModel>
<i class="bi bi-chat-left" aria-hidden="true"></i> Messenger
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=MessengerSendPageViewModel>
<i class="bi bi-send" aria-hidden="true"></i> Sending Messages
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=MessengerRequestPageViewModel>
<i class="bi bi-arrow-left-right" aria-hidden="true"></i>
Request Messages
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IocPageViewModel>
<i class="bi bi-box-arrow-in-down-right" aria-hidden="true"></i>
Inversion of Control
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=ISettingUpTheViewModelsPageViewModel>
<i class="bi bi-bounding-box" aria-hidden="true"></i> ViewModel Setup
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=ISettingsServicePageViewModel>
<i class="bi bi-wrench" aria-hidden="true"></i> Settings Service
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IRedditServicePageViewModel>
<i class="bi bi-globe-americas" aria-hidden="true"></i> Reddit Service
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IBuildingTheUIPageViewModel>
<i class="bi bi-rulers" aria-hidden="true"></i> Building the UI
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IRedditBrowserPageViewModel>
<i class="bi bi-reddit" aria-hidden="true"></i> The Final Result
</MvvmNavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}
Blazor Hybrid Apps
We can embed a Blazor app into a standard Desktop or MAUI application. Following we will look at two examples - WPF and Avalonia. The same principles apply to WinForms and MAUI.
The AppState Class
For Blazor Hybrid apps, we need a method of communicating between the two application frameworks. This class acts as the link between the native app and the Blazor application. It exposes the page navigation.
public static class AppState
{
public static INavigation Navigation { get; set; } = null!;
}
The contract definition for the navigation action delegates:
public interface INavigation
{
void NavigateTo(string page);
void NavigateTo<TViewModel>() where TViewModel : IViewModelBase;
}
Wpf Blazor Hybrid app
What if we wanted to host a Blazor app inside a native Windows application, a Hybrid Blazor application. Maybe we want to use native WPF controls with Blazor content. The following sample application will show how this is done.
MainWindow.Xaml
Now we can use the BlazorWebView
control to host the Blazor app. For the Navigation, I am using WPF Button
controls. I am binding the Button
to a Dictionary
entry held in the MainWindowViewModel
.
<Window x:Class="MvvmSampleBlazor.Wpf.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"
mc:Ignorable="d"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;
assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:shared="clr-namespace:MvvmSampleBlazor.Wpf.Shared"
Title="WPF MVVM Blazor Hybrid Sample Application"
Height="800" Width="1000" WindowStartupLocation="CenterScreen">
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<ItemsControl x:Name="ButtonsList"
Grid.Column="0" Grid.Row="0" Padding="20"
ItemsSource="{Binding NavigationActions}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding Value.Title}" Padding="10 5"
Margin="0 0 0 10"
Command="{Binding ElementName=ButtonsList,
Path=DataContext.NavigateToCommand}"
CommandParameter="{Binding Key}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<blazor:BlazorWebView Grid.Column="1" Grid.Row="0"
HostPage="wwwroot\index.html"
Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app"
ComponentType="{x:Type shared:App}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Stretch"
TextAlignment="Center"
Padding="0 10"
Background="LightGray"
FontWeight="Bold"
Text="Click on the BlazorWebView control, then CTRL-SHIFT-I or
F12 to open the Browser DevTools window..." />
</Grid>
</Window>
MainWindowViewModel Class
This class defines and manages the command navigation via the AppState
class. When the command is executed, a quick lookup is done and the associated action executed - no switch
or if ... else
logic required.
internal class MainWindowViewModel : ViewModelBase
{
public MainWindowViewModel()
=> NavigateToCommand = new RelayCommand<string>(arg =>
NavigationActions[arg!].Action.Invoke());
public IRelayCommand<string> NavigateToCommand { get; set; }
public Dictionary<string, NavigationAction> NavigationActions { get; } = new()
{
["home"] = new("Introduction", () => NavigateTo("/")),
["observeObj"] = new("ObservableObject", NavigateTo<ObservableObjectPageViewModel>),
["relayCommand"] = new("Relay Commands", NavigateTo<RelayCommandPageViewModel>),
["asyncCommand"] = new("Async Commands", NavigateTo<AsyncRelayCommandPageViewModel>),
["msg"] = new("Messenger", NavigateTo<MessengerPageViewModel>),
["sendMsg"] = new("Sending Messages", NavigateTo<MessengerSendPageViewModel>),
["ReqMsg"] = new("Request Messages", NavigateTo<MessengerRequestPageViewModel>),
["ioc"] = new("Inversion of Control", NavigateTo<IocPageViewModel>),
["vmSetup"] = new("ViewModel Setup", NavigateTo<ISettingUpTheViewModelsPageViewModel>),
["SettingsSvc"] = new("Settings Service", NavigateTo<ISettingsServicePageViewModel>),
["redditSvc"] = new("Reddit Service", NavigateTo<IRedditServicePageViewModel>),
["buildUI"] = new("Building the UI", NavigateTo<IBuildingTheUIPageViewModel>),
["reddit"] = new("The Final Result", NavigateTo<IRedditBrowserPageViewModel>),
};
private static void NavigateTo(string url)
=> AppState.Navigation.NavigateTo(url);
private static void NavigateTo<TViewModel>() where TViewModel : IViewModelBase
=> AppState.Navigation.NavigateTo<TViewModel>();
}
The wrapper record class:
public record NavigationAction(string Title, Action Action);
App.razor
We need to expose the Navigation from Blazor to the native app.
@inject NavigationManager NavManager
@inject IMvvmNavigationManager MvvmNavManager
@implements MvvmSampleBlazor.Wpf.States.INavigation
<Router AppAssembly="@typeof(Core.Root).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(NewMainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(NewMainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code
{
protected override void OnInitialized()
{
AppState.Navigation = this;
base.OnInitialized();
// force refresh to overcome Hybrid app not initializing WebNavigation
MvvmNavManager.ForceNavigationManagerUpdate(NavManager);
}
public void NavigateTo(string page)
=> NavManager.NavigateTo(page);
public void NavigateTo<TViewModel>() where TViewModel : IViewModelBase
=> MvvmNavManager.NavigateTo<TViewModel>(new NavigationOptions());
}
Note: Due to a quirk with the BlazorWebView
control and IOC navigation using MvvmNavigationManager
will throw the following exception:
System.InvalidOperationException: ''WebViewNavigationManager' has not been initialized.'
To overcome this, we need to refresh the internal NavigationManager
reference in the MvvmNavigationManager
class. I'm using reflection to do this:
public static class NavigationManagerExtensions
{
public static void ForceNavigationManagerUpdate(
this IMvvmNavigationManager mvvmNavManager, NavigationManager navManager)
{
FieldInfo? prop = mvvmNavManager.GetType().GetField("_navigationManager",
BindingFlags.NonPublic | BindingFlags.Instance);
prop!.SetValue(mvvmNavManager, navManager);
}
}
App.xaml.cs
Lastly, we need to wire it all up:
public partial class App
{
public App()
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
IServiceCollection services = builder.Services;
services.AddWpfBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
services
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServicesWpf()
.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.Hybrid;
});
#if DEBUG
builder.Logging.SetMinimumLevel(LogLevel.Trace);
#endif
services.AddScoped<MainWindow>();
Resources.Add("services", services.BuildServiceProvider());
}
}
Avalonia (Windows only) Blazor Hybrid app
For Avalonia, we will need a wrapper for the BlazorWebView
control. Luckily, there is a 3rd-party class: Baksteen.Avalonia.Blazor - Github Repo. I've included the class as we need to update it for the latest support libraries breaking changes.
MainWindow.xaml
Works the same as the WPF version, however we are using the Baksteen wrapper for the BlazorWebView
control.
<Window
x:Class="MvvmSampleBlazor.Avalonia.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:blazor="clr-namespace:Baksteen.Avalonia.Blazor;assembly=Baksteen.Avalonia.Blazor"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:MvvmSampleBlazor.Avalonia.ViewModels"
Height="800" Width="1200" d:DesignHeight="500" d:DesignWidth="800"
x:DataType="vm:MainWindowViewModel"
Title="Avalonia MVVM Blazor Hybrid Sample Application" Background="DarkGray"
CanResize="True" SizeToContent="Manual" mc:Ignorable="d">
<Design.DataContext>
<vm:MainWindowViewModel />
</Design.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<ItemsControl x:Name="ButtonsList"
Grid.Column="0" Grid.Row="0" Padding="20"
ItemsSource="{Binding NavigationActions}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding Value.Title}"
Padding="10 5" Margin="0 0 0 10"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding ElementName=ButtonsList,
Path=DataContext.NavigateToCommand}"
CommandParameter="{Binding Key}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<blazor:BlazorWebView Grid.Column="1" Grid.Row="0"
HostPage="index.html"
RootComponents="{DynamicResource rootComponents}"
Services="{DynamicResource services}" />
<Label Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Center"
Padding="0 10"
Foreground="Black"
FontWeight="Bold"
Content="Click on the BlazorWebView control, then CTRL-SHIFT-I or
F12 to open the Browser DevTools window.." />
</Grid>
</Window>
MainWindow.Axaml.cs
We can now wire up the control in the code-behind:
public partial class MainWindow : Window
{
public MainWindow()
{
IServiceProvider? services = (Application.Current as App)?.Services;
RootComponentsCollection rootComponents =
new() { new("#app", typeof(HybridApp), null) };
Resources.Add("services", services);
Resources.Add("rootComponents", rootComponents);
InitializeComponent();
}
}
HybridApp.razor
We need to expose the Navigation from Blazor to the native app.
Note: We are using a different name for the app.razor
to work around path/folder and naming issues.
@inject NavigationManager NavManager
@inject IMvvmNavigationManager MvvmNavManager
@implements MvvmSampleBlazor.Wpf.States.INavigation
<Router AppAssembly="@typeof(Core.Root).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(NewMainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(NewMainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code
{
protected override void OnInitialized()
{
AppState.Navigation = this;
base.OnInitialized();
// force refresh to overcome Hybrid app not initializing WebNavigation
MvvmNavManager.ForceNavigationManagerUpdate(NavManager);
}
public void NavigateTo(string page)
=> NavManager.NavigateTo(page);
public void NavigateTo<TViewModel>() where TViewModel : IViewModelBase
=> MvvmNavManager.NavigateTo<TViewModel>(new NavigationOptions());
}
Note: Avalonia has the same quirk as WPF, so the same workaround is used.
Program.cs
Lastly, we need to wire it all up:
internal class Program
{
[STAThread]
public static void Main(string[] args)
{
HostApplicationBuilder appBuilder = Host.CreateApplicationBuilder(args);
appBuilder.Logging.AddDebug();
appBuilder.Services.AddWindowsFormsBlazorWebView();
#if DEBUG
appBuilder.Services.AddBlazorWebViewDeveloperTools();
#endif
appBuilder.Services
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServicesWpf()
.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.Hybrid;
});
using IHost host = appBuilder.Build();
host.Start();
try
{
BuildAvaloniaApp(host.Services)
.StartWithClassicDesktopLifetime(args);
}
finally
{
Task.Run(async () => await host.StopAsync()).Wait();
}
}
private static AppBuilder BuildAvaloniaApp(IServiceProvider serviceProvider)
=> AppBuilder.Configure(() => new App(serviceProvider))
.UsePlatformDetect()
.LogToTrace();
}
Bonuses Blazor Components (Controls)
Building the Blazor sample app, I needed a TabControl
and ListBox
components (controls) for Blazor. So I rolled my own. These components can be found in their own projects in the solution and can be used in your own project. There is a support library for common code. Both components support keyboard navigation.
TabControl Usage
<TabControl>
<Panels>
<TabPanel Title="Interactive Sample">
<div class="posts__container">
<SubredditWidget />
<PostWidget />
</div>
</TabPanel>
<TabPanel Title="Razor">
@StaticStrings.RedditBrowser.sample1Razor.MarkDownToMarkUp()
</TabPanel>
<TabPanel Title="C#">
@StaticStrings.RedditBrowser.sample1csharp.MarkDownToMarkUp()
</TabPanel>
</Panels>
</TabControl>
Above is the code for the Reddit browser example.
ListBox control usage
<ListBox TItem=Post ItemSource="ViewModel!.Posts"
SelectedItem=@ViewModel.SelectedPost
SelectionChanged="@(e => InvokeAsync(() => ViewModel.SelectedPost = e.Item))">
<ItemTemplate Context="post">
<div class="list-post">
<h3 class="list-post__title">@post.Title</h3>
@if (post.Thumbnail is not null && post.Thumbnail != "self")
{
<img src="@post.Thumbnail"
onerror="this.onerror=null; this.style='display:none';"
alt="@post.Title" class="list-post__image" />
}
</div>
</ItemTemplate>
</ListBox>
Properties and events:
TItem
is the type of each item. By setting the type, the ItemTemplate
has a strongly typed Context
ItemSource
points to the Collection
of type TItem
SelectedItem
is to set the initial TItem
SelectionChanged
event is raised when an item is selected.
The above code is part of the SubredditWidget
component for displaying a list titles & images (if exists) of a specific subreddit.
References
Summary
We have a simple to use Blazor MVVM library, called Blazing.MVVM, that supports all of the functionality, like source generator support. We have also explored converting an existing Xamarin Community Toolkit sample application into a Blazor WASM app, and also WPF & Avalonia Hybrid applications. If you are already using the Mvvm Community Toolkit, then using it in Blazor is a no-brainer. If you are already familiar with MVVM, then using Blazing.MVVM in your own project should be straight forward. If you are using Blazor and not MVVM, but want to, you can using existing documentation, blog articles, Code Project's Quick Answers, StackOverflow support, etc. for learning from other application frameworks and apply to Blazor using the Blazing.MVVM library.
History
- 30th July, 2023 - v1.0 - Initial release
- 9th October, 2023 - v1.1 - Added
MvvmLayoutComponentBase
to support MVVM in the MainLayout.razor
with updated sample project - 1st November, 2023 - v1.2 - Added .NET 7.0+
Blazor Server App
support; new hosting model configuration support added; pre-release of .NET 8.0 RC2 (Auto) Blazor WebApp;
- 21st November, 2023 - v1.4 - Updated to .NET 8.0 + sample Blazor Web App project supporting Auto-mode; updated section Getting Started for .NET 8.0 Blazor Web Apps