Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / Blazor

Blazing.Mvvm - Blazor Server, WebAssembly, & Hybrid using Mvvm Community Toolkit

5.00/5 (12 votes)
20 Nov 2023CPOL10 min read 31.4K  
MVVM made simple via Blazing.Mvvm Library using the Microsoft Community Toolkit
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.

Image 1

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

  1. Add the Blazing.Mvvm Nuget package to your project.

  2. Enable MvvmNavigation support in your Program.cs file:

    • Blazor Server App:

      C#
      builder.Services.AddMvvmNavigation(options =>
      { 
          options.HostingModel = BlazorHostingModel.Server;
      }); 
    • Blazor WebAssembly App:

      C#
      builder.Services.AddMvvmNavigation();
    • Blazor Web App (new to .NET 8.0)

      C#
      builder.Services.AddMvvmNavigation(options =>
      { 
          options.HostingModel = BlazorHostingModel.WebApp;
      });  
    • Blazor Hybrid App (WinForm, WPF, Avalonia, MAUI):

      C#
      builder.Services.AddMvvmNavigation(options =>
      { 
          options.HostingModel = BlazorHostingModel.Hybrid;
      });  
  3. Create a ViewModel inheriting the ViewModelBase class:
    C#
    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();
    }
  4. Register the ViewModel in your Program.cs file:
    C#
    builder.Services.AddTransient<FetchDataViewModel>();
  5. Create your page inheriting the MvvmComponentBase<TViewModel> component:
    Razor
    @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>
    }
  6. Optionally, modify the NavMenu.razor to use MvvmNavLink for Navigation by ViewModel:
    XML
    <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:

C#
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:

C#
mvvmNavigationManager.NavigateTo<ITestNavigationViewModel>();

The same principle works with the MvvmNavLink component:

XML
<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:

  1. The ViewModelBase
  2. The MvvmComponentBase

The MvvmComponentBase handles wiring up the ViewModel to the component.

C#
public abstract class MvvmComponentBase<TViewModel>
    : ComponentBase, IView<TViewModel>
    where TViewModel : IViewModelBase
{
    [Inject]
    protected TViewModel? ViewModel { get; set; }

    protected override void OnInitialized()
    {
        // Cause changes to the ViewModel to make Blazor re-render
        ViewModel!.PropertyChanged += (_, _) => InvokeAsync(StateHasChanged);
        base.OnInitialized();
    }

    protected override Task OnInitializedAsync()
        => ViewModel!.OnInitializedAsync();
}

And here is the ViewModelBase class that wraps the ObservableObject:

C#
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!

Image 2

MvvmNavigationManager Class

When the MvvmNavigationManager is initialized by the IOC container as a Singleton, the class will examine all assemblies and internally caches all ViewModels (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.

C#
/// <summary>
/// Provides an abstraction for querying and managing navigation via ViewModel 
//  (class/interface).
/// </summary>
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();
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine the
    ///  URI to navigate to.</typeparam>
    /// <param name="forceLoad">If true, bypasses client-side routing 
    /// and forces the browser to load
    ///  the new page from the server, whether or not the URI would normally 
    /// be handled by the client-side router.</param>
    /// <param name="replace">If true, replaces the current entry in the history stack.
    /// If false,
    ///  appends the new entry to the history stack.</param>
    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!);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> to use to
    ///  determine the URI to navigate to.</typeparam>
    /// <param name="options">Provides additional <see cref="NavigationOptions"/>
    ///  .</param>
    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);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine
    ///  the URI to navigate to.</typeparam>
    /// <param name="relativeUri">relative URI &/or QueryString appended to 
    ///  the navigation Uri
    ///  .</param>
    /// <param name="forceLoad">If true, bypasses client-side routing and 
    /// forces the browser to load
    ///  the new page from the server, whether or not the URI would normally 
    ///  be handled by the client-side router.</param> 
    /// <param name="replace">If true, replaces the current entry 
    /// in the history stack. If false,
    ///  appends the new entry to the history stack.</param>
    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!);
    }

    /// <summary>
    /// Navigates to the specified associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    /// to use to determine
    ///  the URI to navigate to.</typeparam>
    /// <param name="relativeUri">relative URI &/or QueryString appended 
    /// to the navigation Uri.</param>
    /// <param name="options">Provides additional 
    /// <see cref="NavigationOptions"/>.</param>
    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);
    }

    /// <summary>
    /// Get the <see cref="IViewModelBase"/> associated URI.
    /// </summary>
    /// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> 
    ///  to use to determine the URI to navigate to.</typeparam>
    /// <returns>A relative URI path.</returns>
    /// <exception cref="ArgumentException"></exception>
    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)
            {
                // avoid issue with unit tests
                continue;
            }

            // does the assembly contain the required types?
            if (!items.Any())
                continue;

            foreach ((Type Type, Type? Argument) item in items)
            {
                Attribute? attribute = item.Type.GetCustomAttributes()
                                           .FirstOrDefault(a => a is RouteAttribute);

                // is this a page or a component?
                if (attribute is null)
                    continue;

                // we have a page, let's reference it!
                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;

        // Find the generic type definition for MvvmComponentBase<> 
        // with the correct type argument
        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;

        // Check if the type constraint is a subtype of MvvmComponentBase<>
        if (!ComponentBaseType.IsAssignableFrom(type))
            return default;

        // get all interfaces
        Type[] interfaces = ComponentBaseType
            .GetGenericArguments()[0]
            .GetInterfaces();

        // Check if the type argument of IView<> implements IViewModel
        if (interfaces.FirstOrDefault(i => i.Name == $"{viewModelType.Name}") is null)
            return default;

        // all checks passed, so return the type with the argument type declared 
        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 

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.

C#
/// <summary>
/// A component that renders an anchor tag, automatically toggling its 'active'
/// class based on whether its 'href' matches the current URI. Navigation is based on
/// ViewModel (class/interface).
/// </summary>
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!;

    /// <summary>
    /// Gets or sets the CSS class name applied to the NavLink when the
    /// current route matches the NavLink href.
    /// </summary>
    [Parameter]
    public string? ActiveClass { get; set; }

    /// <summary>
    /// Gets or sets a collection of additional attributes 
    /// that will be added to the generated
    /// <c>a</c> element.
    /// </summary>
    [Parameter(CaptureUnmatchedValues = true)]
    public IDictionary<string, object>? AdditionalAttributes { get; set; }

    /// <summary>
    /// Gets or sets the computed CSS class based on whether or not the link is active.
    /// </summary>
    protected string? CssClass { get; set; }

    /// <summary>
    /// Gets or sets the child content of the component.
    /// </summary>
    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    /// <summary>
    /// Gets or sets a value representing the URL matching behavior.
    /// </summary>
    [Parameter]
    public NavLinkMatch Match { get; set; }

    /// <summary>
    ///Relative URI &/or QueryString appended to the associate URI.
    /// </summary>
    [Parameter]
    public string? RelativeUri { get; set; }

    /// <inheritdoc />
    protected override void OnInitialized()
    {
        // We'll consider re-rendering on each location change
        NavigationManager.LocationChanged += OnLocationChanged;
    }

    /// <inheritdoc />
    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();
    }

    /// <inheritdoc />
    public void Dispose()
    {
        // To avoid leaking memory, it's important to detach 
        // any event handlers in 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)
    {
        // We could just re-render always, but for this component we know the
        // only relevant state change is to the _isActive property.
        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)
        {
            // Special case: highlight links to http://host/path/ even if you're
            // at http://host/path (with no trailing slash)
            //
            // This is because the router accepts an absolute URI value of "same
            // as base URI but without trailing slash" as equivalent to "base URI",
            // which in turn is because it's common for servers to return the same page
            // for http://host/vdir as they do for host://host/vdir/ as it's no
            // good to display a blank page in that case.
            if (_hrefAbsolute[^1] == '/'
                && _hrefAbsolute.StartsWith(currentUriAbsolute,
                    StringComparison.OrdinalIgnoreCase))
                return true;
        }

        return false;
    }

    /// <inheritdoc/>
    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)
                && (
                    // Only match when there's a separator character 
                    // either at the end of the
                    // prefix or right after it.
                    // Example: "/abc" is treated as a prefix of "/abc/def" 
                    // but not "/abcdef"
                    // Example: "/abc/" is treated as a prefix of "/abc/def" 
                    // but not "/abcdef"
                    prefixLength == 0
                    || !char.IsLetterOrDigit(prefix[prefixLength - 1])
                    || !char.IsLetterOrDigit(value[prefixLength])
                );
        }

        return false;
    }
}

Tests

Tests are included for the navigation and messaging.

Image 3

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.

Image 4

Changes made to Xamarin Sample for Blazor

The MvvmSample.Core project remains mostly unchanged, I've added base classes to the ViewModels to enable Blazor binding updates.

So, as an example, the SamplePageViewModel was changed from:

C#
public class MyPageViewModel : ObservableObject
{
    // code goes here
}

to:

C#
public class MyPageViewModel : ViewModelBase
{
    // code goes here
}

The ViewModelBase wraps the ObservableObject class. No other changes required.

For the Xamarin Pages, wiring up the DataContext is done with:

C#
BindingContext = Ioc.Default.GetRequiredService<MyPageViewModel>();

With Blazing.MVVM, it is simply:

Razor
@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

Image 5

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:

C#
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:

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>
NavMenu.razor

Lastly, we will wire up the blazor navigation:

XML
<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.

C#
public static class AppState
{
    public static INavigation Navigation { get; set; } = null!;
}

The contract definition for the navigation action delegates:

C#
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.

Image 6

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.

XML
<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.

C#
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:

C#
public record NavigationAction(string Title, Action Action);
App.razor

We need to expose the Navigation from Blazor to the native app.

Razor
@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:

C#
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:

C#
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());

        // will throw an error
        //MainWindow = provider.GetRequiredService<MainWindow>();
        //MainWindow.Show();
    }
}

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.

Image 7

MainWindow.xaml

Works the same as the WPF version, however we are using the Baksteen wrapper for the BlazorWebView control.

XML
<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:

C#
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.

Razor
@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:

C#
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

XML
<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

XML
<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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)