[Proposal] IServiceCollection extension methods to register Views & ViewModels
See original GitHub issueIServiceCollection extension methods to register Views & ViewModels
- Proposed
- Prototype
- Implementation
- iOS Support
- Android Support
- macOS Support
- Windows Support
- Unit Tests
- Sample
- Documentation: https://github.com/MicrosoftDocs/CommunityToolkit/pull/118
Link to Discussion
Summary
This proposal is to add IServiceCollection
extension methods to CommunityToolkit.Maui
which registers Views and the corresponding ViewModels together. These extension methods would also allow for optionally registering Shell routes for pages not explicitly added to AppShell.xaml
Motivation
When implementing the MVVM pattern, it is common to resolve Views, ViewModels, and required dependencies from the IServiceCollection
container. Since Views and ViewModels need to be registered in the IServiceCollection
and they typically share a direct 1:1 relationship, it makes sense to provide helper methods to register both the View and ViewModel in a single line of code versus having to register them independently.
Furthermore, there may be requirements to register specific pages with Shell routing in code using Routing.RegisterRoute()
instead of directly adding pages to the markup of AppShell.xaml
. The extension methods to register Views and ViewModels in IServiceCollection
could further simplify development by providing a means to register pages in Shell routing.
Detailed Design
This proposal introduces the following IServiceCollection
extension methods. This option introduces more extension methods in a slightly more verbose yet fluent API with the ServiceLifetime
part of the method name.
public static class IServiceCollectionExtensions
{
/// <summary>
/// Adds a Page or View of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> with <see cref="ServiceLifetime.Singleton"/> lifetime.
/// </summary>
/// <typeparam name="TView">The type of the View to add. Constrained to <see cref="Microsoft.Maui.Controls.VisualElement"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddSingletonViewAndViewModel<TView, TViewModel>(this IServiceCollection services)
where TView : Microsoft.Maui.Controls.BindableObject
where TViewModel : INotifyPropertyChanged
{
services.AddSingleton<TViewModel>();
return services.AddSingleton<TView>();
}
/// <summary>
/// Adds a Page of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> with <see cref="ServiceLifetime.Singleton"/> lifetime
/// and registers a MAUI Shell route in <see cref="Routing"/> using the value of <paramref name="route"/> as the route.
/// </summary>
/// <typeparam name="TView">The type of the View to add. Constrained to <see cref="Microsoft.Maui.Controls.Page"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddSingletonViewAndViewModelWithShellRoute<TView, TViewModel>(this IServiceCollection services, string route, RouteFactory? factory = null)
where TView : Microsoft.Maui.Controls.Page
where TViewModel : INotifyPropertyChanged
{
if(factory is null)
{
Routing.RegisterRoute(route, typeof(TView));
}
else
{
Routing.RegisterRoute(route, factory);
}
return services.AddSingletonViewAndViewModel<TView, TViewModel>();
}
/// <summary>
/// Adds a Page or View of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> with <see cref="ServiceLifetime.Scoped"/> lifetime.
/// </summary>
/// <typeparam name="TView">The type of the View to add. Constrained to <see cref="Microsoft.Maui.Controls.VisualElement"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddScopedViewAndViewModel<TView, TViewModel>(this IServiceCollection services)
where TView : Microsoft.Maui.Controls.BindableObject
where TViewModel : INotifyPropertyChanged
{
services.AddScoped<TViewModel>();
return services.AddScoped<TView>();
}
/// <summary>
/// Adds a Page of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> with <see cref="ServiceLifetime.Scoped"/> lifetime
/// and registers a MAUI Shell route in <see cref="Routing"/> using the value of <paramref name="route"/> as the route.
/// </summary>
/// <typeparam name="TView">The type of the View to add. Constrained to <see cref="Microsoft.Maui.Controls.Page"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddScopedViewAndViewModelWithShellRoute<TView, TViewModel>(this IServiceCollection services, string route, RouteFactory? factory = null)
where TView : Microsoft.Maui.Controls.Page
where TViewModel : INotifyPropertyChanged
{
if(factory is null)
{
Routing.RegisterRoute(route, typeof(TView));
}
else
{
Routing.RegisterRoute(route, factory);
}
return services.AddScopedViewAndViewModel<TView, TViewModel>();
}
/// <summary>
/// Adds a Page or View of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> with <see cref="ServiceLifetime.Transient"/> lifetime.
/// </summary>
/// <typeparam name="TView">The type of the View to add. Constrained to <see cref="Microsoft.Maui.Controls.VisualElement"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddTransientViewAndViewModel<TView, TViewModel>(this IServiceCollection services)
where TView : Microsoft.Maui.Controls.BindableObject
where TViewModel : INotifyPropertyChanged
{
services.AddTransient<TViewModel>();
return services.AddTransient<TView>();
}
/// <summary>
/// Adds a Page of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> with <see cref="ServiceLifetime.Transient"/> lifetime
/// and registers a MAUI Shell route in <see cref="Routing"/> using the value of <paramref name="route"/> as the route.
/// </summary>
/// <typeparam name="TView">The type of the View to add. Constrained to <see cref="Microsoft.Maui.Controls.Page"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddTransientViewAndViewModelWithShellRoute<TView, TViewModel>(this IServiceCollection services, string route, RouteFactory? factory = null)
where TView : Microsoft.Maui.Controls.Page
where TViewModel : INotifyPropertyChanged
{
if(factory is null)
{
Routing.RegisterRoute(route, typeof(TView));
}
else
{
Routing.RegisterRoute(route, factory);
}
return services.AddTransientViewAndViewModel<TView, TViewModel>();
}
}
Usage Syntax
This example illustrates how the extension methods could be used within MauiProgram
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCommunityToolkit()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.Services
// Adds HomePage and HomePageViewModel with ServiceLifetime.Transient
.AddTransientViewAndViewModel<HomePage, HomePageViewModel>()
// Adds HomePage and HomePageViewModel with ServiceLifetime.Singleton
.AddSingletonViewAndViewModel<HomePage, HomePageViewModel>()
// Adds HomePage and HomePageViewModel with ServiceLifetime.Transient and Shell route: "HomePage"
.AddTransientViewAndViewModelWithShellRoute<HomePage, HomePageViewModel>()
// Adds HomePage and HomePageViewModel with ServiceLifetime.Transient and Shell route: "MyCustomRoute"
.AddTransientViewAndViewModelWithShellRoute<HomePage, HomePageViewModel>("MyCustomRoute")
// Adds HomePage and HomePageViewModel with ServiceLifetime.Singleton and Shell route: "HomePage"
.AddSingletonViewAndViewModelWithShellRoute<HomePage, HomePageViewModel>()
// Adds HomePage and HomePageViewModel with ServiceLifetime.Singleton and Shell route: "MyCustomRoute"
.AddSingletonViewAndViewModelWithShellRoute<HomePage, HomePageViewModel>("MyCustomRoute");
return builder.Build();
}
}
Drawbacks
These extension methods are MVVM specific and this repo/package is MAUI specific. Due to the necessary Microsoft.Maui.Controls.VisualElement
and Microsoft.Maui.Controls.Page
type constraints needed on the methods they cannot be moved to the CommunityToolkit.Mvvm
which is a more natural fit. Adding the methods to this repo/package may be confusing to some developers.
These methods also do not address the following scenarios:
- What if the Views and ViewModels need different lifetimes? E.g. Scoped for
TView
and Transient forTViewModel
? - What if multiple Views/Pages use the same ViewModel?
- Why doesn’t the helper method confirm
TView
sBindingContext
isTViewModel
?
Alternatives
These extension methods are merely convenience methods for developers. The alternative is to continue registering Views and ViewModels in the IServiceCollection
independently and then separately registering routes via Routing.RegisterRoute();
if needed.
Unresolved Questions
- Does
ServiceLifetime.Scoped
make sense in the context of a MAUI application for Pages, Views, and ViewModels? Can someone provide a concrete example of using theScoped
lifetime? - If Option 1 were to be chosen, should the
lifetime
parameter have a default value set? If yes, isServiceLifetime.Transient
the correct value or should it beServiceLifetime.Singleton
? - Are method names too verbose? If yes, how would you reduce the verbosity?
- Is there a better API surface we should consider?
Removed Suggestions
Option 1
This option introduces fewer extension methods by utilizing a parameter to specify the IServiceCollection
container lifetime of the Page or View and ViewModel. In this option, ServiceLifetime
has a default value of ServiceLifetime.Transient
. This may or may not be the correct default value, or perhaps a default value should not be provided at all.
public static class IServiceCollectionExtensions
{
/// <summary>
/// Adds a Page or View of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/>.
/// </summary>
/// <typeparam name="TView">The type of the Page or View to add. Constrained to <see cref="Microsoft.Maui.Controls.VisualElement"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <param name="lifetime">The <see cref="ServiceLifetime"/> by which the View and ViewModel will be registered in <see cref="IServiceCollection"/>. Defaults to <see cref="ServiceLifetime.Transient"/></param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddViewAndViewModel<TView, TViewModel>(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient)
where TView : Microsoft.Maui.Controls.VisualElement
where TViewModel : INotifyPropertyChanged
{
switch (lifetime)
{
case ServiceLifetime.Singleton:
services.AddSingleton<TViewModel>();
return services.AddSingleton<TView>();
case ServiceLifetime.Scoped:
services.AddScoped<TViewModel>();
return services.AddScoped<TView>();
case ServiceLifetime.Transient:
services.AddTransient<TViewModel>();
return services.AddTransient<TView>();
default:
throw new ArgumentOutOfRangeException(nameof(lifetime));
}
}
/// <summary>
/// Adds a Page of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> and registers a MAUI Shell route in
/// <see cref="Routing"/> using <typeparamref name="TView"/> type name as the route.
/// </summary>
/// <typeparam name="TView">The type of the Page to add. Constrained to <see cref="Microsoft.Maui.Controls.Page"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <param name="lifetime">The <see cref="ServiceLifetime"/> by which the View and ViewModel will be registered in <see cref="IServiceCollection"/>. Defaults to <see cref="ServiceLifetime.Transient"/></param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddViewAndViewModelWithShellRoute<TView, TViewModel>(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient)
where TView : Microsoft.Maui.Controls.Page
where TViewModel : INotifyPropertyChanged
{
return services.AddViewAndViewModelWithShellRoute<TView, TViewModel>(typeof(TView).Name, lifetime);
}
/// <summary>
/// Adds a Page of the type specified in <typeparamref name="TView"/> and a ViewModel
/// of the type specified in <typeparamref name="TViewModel"/> to the specified
/// <see cref="IServiceCollection"/> and registers a MAUI Shell route in
/// <see cref="Routing"/> using the value of <paramref name="route"/> as the route.
/// </summary>
/// <typeparam name="TView">The type of the Page to add. Constrained to <see cref="Microsoft.Maui.Controls.Page"/></typeparam>
/// <typeparam name="TViewModel">The type of the ViewModel to add. Constrained to <see cref="INotifyPropertyChanged"/></typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the service to.</param>
/// <param name="route">Route at which this page will be registered with Shell routing.</param>
/// <param name="lifetime">The <see cref="ServiceLifetime"/> by which the View and ViewModel will be registered in <see cref="IServiceCollection"/>. Defaults to <see cref="ServiceLifetime.Transient"/></param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddViewAndViewModelWithShellRoute<TView, TViewModel>(this IServiceCollection services, string route, ServiceLifetime lifetime = ServiceLifetime.Transient)
where TView : Microsoft.Maui.Controls.Page
where TViewModel : INotifyPropertyChanged
{
Routing.RegisterRoute(route, typeof(TView));
return services.AddViewAndViewModel<TView, TViewModel>(lifetime);
}
}
This example illustrates how the extension methods in Option 1 could be used within MauiProgram
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCommunityToolkit()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.Services
// Adds HomePage and HomePageViewModel with ServiceLifetime.Transient (defualt value)
.AddViewAndViewModel<HomePage, HomePageViewModel>()
// Adds HomePage and HomePageViewModel with ServiceLifetime.Transient
.AddViewAndViewModel<HomePage, HomePageViewModel>(ServiceLifetime.Singleton)
// Adds HomePage and HomePageViewModel with ServiceLifetime.Transient (defualt value) and Shell route: "HomePage"
.AddViewAndViewModelWithShellRoute<HomePage, HomePageViewModel>()
// Adds HomePage and HomePageViewModel with ServiceLifetime.Transient (defualt value) and Shell route: "MyCustomRoute"
.AddViewAndViewModelWithShellRoute<HomePage, HomePageViewModel>("MyCustomRoute")
// Adds HomePage and HomePageViewModel with ServiceLifetime.Singleton and Shell route: "MyCustomRoute"
.AddViewAndViewModelWithShellRoute<HomePage, HomePageViewModel>("MyCustomRoute", ServiceLifetime.Singleton);
return builder.Build();
}
}
Issue Analytics
- State:
- Created a year ago
- Comments:17 (9 by maintainers)
Top GitHub Comments
Hey @brminnick, I just wanted to follow up on this and provide some thoughts below on the questions raised during the July 2022 community standup.
@jfversluis
ServiceLifetime
is defined inMicrosoft.Extensions.DependencyInjection
. Thankfully it seems like everyone (including community) preferred Option 2 so this doesn’t even matter.BindingContext
.@pictos
Routing.RegisterRoute(string route, RouteFactory factory)
. Agreed this overload method should be available as well.BindableObject
as constraint for registering Views and ViewModels. As mentioned by @brminnick the non-Shell methods would useBindableObject
as the TView constraint to allow for the widest usage possible.@VladislavAntonyuk
Semi-related to the scenario @VladislavAntonyuk mentioned is what happens if there are multiple Views/Pages that use the same ViewModel? I can envision this in scenarios where a View is purpose-built for Desktop/TV (Mac/Windows/Tizen) and another View is created for Mobile.
The original intent of these helper extension methods iso make developers’ lives easier within the 80/20 rule. There’s no way we’ll be able to cover 100% of the real-life scenarios, but I think we can capture at least 80% of the use-cases and that seems like a “win” to me.
Look forward to everyone’s thoughts and guidance for moving forward (or not)!
We’ve reached the minimum threshold of 50% approval and you are now clear to move forward and implement this Proposal, @rjygraham! 🎉
I’ve moved this to the
Proposal Approved Column on our Project Board
, added theapproved
tag and assigned it to @rjygraham.