johnnys.newsjohnnys.news

by Joachim Leonfellner ✌️ i'm an indie dev in ❤️ with side-projects

Mar 15, 2024936 words in 6 min


Sharing ViewModels in .NET MAUI Blazor Hybrid

I’m recently working on a .NET MAUI Blazor Hybrid app which is a migration project from Xamarin.Forms. It has a large codebase of MVVM architecture and I’m trying to reuse as much of the existing code as possible for the first stage of the migration. Some parts of the app are already migrated to Blazor components while others are still XAML-based. It’s a mix-and-match of pure XAML pages and pages with embedded Blazor components via BlazorWebView. One of the challenges I faced was how to handle transient registered ViewModels in the Blazor components. In this post, I’ll share how I solved this problem.

I have created a simple .NET MAUI Blazor Hybrid app with single page that contains a BlazorWebView and a native MAUI Label - both should share the same ViewModel instance.

Problem

In this existing MVVM based codebase, ViewModels contain a lot of business logic and handle how and when to update the UI via data binding. The concept of data binding is not fully applicable to Blazor components where we need to call StateHasChanged to invalidate the current component and update the UI. One way to handle such a situation is to listen to the ViewModel’s property change events and call StateHasChanged when a property changes. Not the most elegant solution, but it works.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

@page "/"
@inject MainViewModel _mainViewModel

<!-- ... -->

@code {

protected override void OnInitialized()
{
_mainViewModel.PropertyChanged += MainViewModel_PropertyChanged;
}

private void MainViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e))
{
//react on specific property changes and invalidate the component
StateHasChanged();
}
}

To do this we of course need the ViewModel instance in the Blazor component. The most straightforward way to do this is to just inject the ViewModel into the component. But this approach has a problem - we of course have registered the ViewModel as transient in the DI container. This means that every time we inject it somewhere, we get a new instance. In general this is a good thing (ViewModels shouldn’t be Singletons ☝️), but in this case we want to share the ViewModel instance between the MAUI View and the Blazor component since it’s just another UI control on a ContentPage and we want to access the ViewModel instance that is already used by this page.

To verify that the ViewModel instance is shared between the MAUI View and the Blazor component, I simply log the HashCode of the ViewModel to a MAUI Label and a Blazor heading.

As we can see, the HashCode of the ViewModel is different in the MAUI Label and the Blazor heading. This is because the ViewModel is transient - the first instance is created when injected into the ContentPage and the second instance is created when injected into the Blazor component.

MainPage.xaml.cs

1
2
3
4
5
public MainPage(MainViewModel viewmodel)
{
BindingContext = viewmodel;
....
}

Main.razor

1
2
@page "/"
@inject MainViewModel _mainViewModel

Solution

We don’t want to inject the ViewModel directly into the Blazor component, but pass it to the BlazorWebView from the ContentPage. We can do this by passing a Dictionary<string, object> to the BlazorWebView’s Parameters property. This dictionary can contain any objects that we want to pass to the Blazor component. In our case, we want to pass the ViewModel instance.

1
2
3
4
5
6
7
8
9
public MainPage(MainViewModel viewmodel)
{
BindingContext = viewmodel;
InitializeComponent();
MyBlazorWebView.Parameters = new Dictionary<string, object>
{
{ "ViewModel", viewmodel }
};
}

on the Blazor side, we can use a CascadingValue to pass objects down the component hierarchy. This way we can retrieve the ViewModel instance from the dictionary and pass it to the Blazor component.

1
2
3
4
5
6
7
8
9
10
11
12
<Router AppAssembly="@typeof(MauiProgram).Assembly">
<Found Context="routeData">
<CascadingValue Value="@ViewModel">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
</CascadingValue>
</Found>
</Router>

@code {
[Parameter]
public INotifyPropertyChanged ViewModel { get; set; }
}

In this example, I’m just using INotifyPropertyChanged as the type of the ViewModel, in a more complex scenario you might want to use a base class or an interface that your ViewModels implement.
In the component, we can now access the ViewModel instance via a CascadingParameter and subscribe to its PropertyChanged event.

1
2
3
4
[CascadingParameter]
public INotifyPropertyChanged ViewModel { get; set; }

MainViewModel _mainViewModel => ViewModel as MainViewModel;

This way we can access the ViewModel instance that is already used by the ContentPage and share it with the Blazor component. The ViewModel instance is now shared between the MAUI View and the Blazor component. In the demo app we can see that the HashCode of the ViewModel is the same in the MAUI Label and the Blazor heading.

With the changes we made, we also need to modify App.xaml.cs, since we don’t want to manually create the MainPage anymore. Instead we are going to use the IServiceProvider to resolve the MainPage from the DI container with its dependencies.

1
2
3
4
5
6
public App()
{
InitializeComponent();

MainPage = new MainPage();
}

becomes

1
2
3
4
5
6
public App(IServiceProvider serviceProvider)
{
InitializeComponent();

MainPage = serviceProvider.GetRequiredService<MainPage>();
}

I hope this post was helpful to you. If you have any questions or feedback, feel free to leave a comment below.
You can find the source code of the demo app on GitHub.

Happy coding!