Have you ever done any mobile development in the Microsoft ecosystem? Then you might've heard about Xamarin. The technique, at this point synonymous with the company that originally built it, goes back all the way to 2011. It's been Microsoft's main mobile development offering since 2016, when they acquired the company.

Xamarin allows developers to use C# code to develop applications for iOS, Android, and UWP primarily, using Visual Studio. It does all of this from a shared codebase, meaning that unless you want to do something that's platform-specific, you can achieve most of what you want from a single shared library.

The advent of Xamarin.Forms provided an additional abstraction layer on top of that shared codebase with which you can define your user interface in a shared fashion through XAML. To improve the development experience, Microsoft created a lot of additional tooling over the years, making Xamarin a complete offering for mobile developers. The natural next step of that effort was introduced at Build 2020 in the form of the .NET Multi-platform App UI (.NET MAUI). In this article, I'll dive deeper into what it is, and what the biggest changes are compared to Xamarin.Forms.

What is .NET MAUI?

.NET MAUI is the evolution of what is currently Xamarin.Forms. There's now a single .NET 6 Base Class Library (BCL) where the different types of workloads, such as iOS and Android, are now all part of .NET. It effectively abstracts the details of the underlying platform away from your code. If you're running your app on iOS, macOS, or Android, you can now rely on that common BCL to deliver a consistent API and behavior. On Windows, CoreCLR is the .NET runtime that takes care of this.

Even though this BCL allows you to run the same shared code on different platforms, it doesn't allow you to share your user interface definitions. The need for an additional layer of UI abstraction is the problem that .NET MAUI will solve, while simultaneously branching out towards various additional desktop scenarios.

Looking at it from an architectural perspective, most of the code you write will interact with the upper two layers of the diagram shown in Figure 1. The .NET MAUI layer handles communication with the layers below it. However, it won't prevent you from calling into these layers if you need access to a platform-specific feature.

Figure 1: The architecture behind .NET MAUI
Figure 1: The architecture behind .NET MAUI

Making the move to .NET MAUI is also an opportunity for the Xamarin.Forms team to rebuild the eight-year-old toolkit from the ground up and tackle some of the issues that have been lingering at a lower level. Redesigning for performance and extensibility is an integral part of this effort. Companies all over the world use Xamarin extensively, so making these changes in the current toolkit quickly becomes nearly impossible. If you've previously used Xamarin.Forms to build cross-platform user interfaces, you'll notice many similarities when starting to look into .NET MAUI. There are a few differences worth exploring though.

The New Handlers Infrastructure

If you've ever done any Xamarin.Forms development, you might be aware of the concept of a renderer. This is a piece of code that takes care of rendering a specific control to the screen in a consistent way across each platform. As a developer, you can create a custom renderer that allows you to target a specific type of control on a specific platform and override its built-in behavior. For example, if you want to remove the underline beneath an Android input field, you could write a single custom renderer that would apply to all your Entry fields and do just that.

In .NET MAUI the concept of renderers becomes obsolete, but bringing your current renderers to .NET MAUI can be done through the compatibility APIs. Moving forward, handlers will replace renderers entirely. But why? There are a few underlying architectural issues within the current Xamarin.Forms implementation that have spurred the development of an alternative approach.

  • The renderer and control are tightly coupled to one another from within Xamarin.Forms, which isn't ideal.
  • You register your custom renderers on an assembly level. This means that for every control, the platform performs an assembly scan to find out if a custom renderer should be applied while starting up your app. This can be a rather slow process, relatively speaking. The Xamarin.Forms platform renderers also inject additional view elements that impact performance.
  • Xamarin.Forms is an abstraction layer on top of multiple different platforms. Because of this abstraction, it can sometimes be quite difficult to reach the platform-specific code you're looking to change from within the confines of a renderer. Private methods could block your way to the thing you want to customize. The Xamarin.Forms team built additional constructs, such as the platform-specifics API to get around this, but its usage is typically not obvious to users.
  • Creating a custom renderer isn't very intuitive. You need to inherit from a base renderer type that isn't well-known, and you can say the same for the methods that you need to override. When you only want your custom renderer to apply to a specific instance of a control, you need to create a custom type (e.g., a CustomButton), target the renderer at that, and use that control instead of just a regular Button. This adds a lot of unnecessary code overhead.

Although those all sound like good reasons to improve, why change now? With this opportunity of reshaping the platform comes the chance for some fundamental rethinking of concepts like these that have been a bit of a sore spot. On the renderers side alone, the benefits are huge when it comes to performance, API simplification and homogenization.

Reshaping the Underlying Infrastructure

The first step in reshaping the underlying infrastructure is to make sure to remove the current tight coupling with the controls. The .NET MAUI team achieved this by putting them behind an interface and having all the individual components interact with the interface. That way, it becomes easy to make different implementations of something like an IButton, while making sure the underlying infrastructure handles all these implementations in the same way. Figure 2 shows how that looks from a conceptual perspective.

Figure 2: Abstracting away the tight coupling to the control implementations
Figure 2: Abstracting away the tight coupling to the control implementations

To prevent the need for assembly scanning with reflection, the team decided to change the way handlers are registered. Instead of registering them on an assembly level through attributes, handlers are explicitly registered by the platform, and you can now explicitly register any custom handlers in your startup code. I'll touch upon how to do this later in this article, but this eliminates the need for the assembly scanning penalty that you get on startup.

When it comes to making things hidden deep inside the native platform more easily reachable, the team has taken the approach of defining a mapper dictionary. The mapper is a dictionary with the properties (and actions) defined in the interface of a specific control that offers direct access to the native view object. Casting this native view object to the right type gives you instant access to platform-specific code from your shared code. The following sample shows how you can call into the mapper dictionary for a generic view and set its background color through a piece of platform-specific code. It also shows how to reach the native view.

#if __ANDROID__

ViewHandler.ViewMapper[nameof(IView.BackgroundColor)] =
    (h, v) => (h.NativeView as AView).SetBackgroundColor(Color.Green);

var textView = label.Handler.NativeView;

#endif

In this sample, you use the generic ViewHandler to reach the background color, because each view has a background color property. Depending on the detail level you need, you can use a more specific handler, such as the ButtonHandler. This exposes the native button control directly, eliminating the need to cast it. The existing built-in platform-specifics API become obsolete because of this new mapper dictionary. Next, let's take a look at how you can change an existing custom renderer into a handler to see how the overhead that currently exists has been improved.

Differences Between Renderers and Handlers

The Xamarin.Forms renderer implementation is fundamentally a platform-specific implementation of a native control. To create a renderer, you perform the following steps:

  • Subclass the control you want to target. Although not required, this is a good convention to adhere to.
  • Create any public-facing properties you need in your control.
  • Create a subclass of the ViewRenderer derived class responsible for creating the native control.
  • Override OnElementChanged to customize the control. This method is called when the control is created on screen.
  • Override OnElementPropertyChanged when wanting to target when a specific property changed its value.
  • Add the ExportRenderer assembly attribute to make it scannable.
  • Consume the new custom control in your XAML file.

Let's see how you can create something similar using .NET MAUI. The process to create a handler is as follows:

  • Create a subclass of the ViewHandler class responsible for creating the native control.
  • Override the CreateNativeView method that renders the native control.
  • Create the mapper dictionary to respond to property changes.
  • Register the handler in the startup class.

Although similarities exist between the two, the .NET MAUI implementation is a lot leaner. A lot of the technical baggage that came with the custom renderers has also been cleaned up, in part due to changes within the .NET MAUI internals. You can find the architecture for the handler infrastructure outlined in Figure 3. This sample indicates the layers a button goes through to render to the device screen.

Figure 3: The .NET MAUI handler architecture
Figure 3: The .NET MAUI handler architecture

Implementing a Handler

To implement a custom handler, start by creating a control-specific interface. You want loose coupling between control and handlers, as mentioned earlier. To avoid holding references to cross-platform controls, you need an interface for your control.

public interface IMyButton : IView
{
    public string Text { get; }
    public Color TextColor { get; }
    void Completed();
}

By having your custom control implement this interface, you can target this specific type of control from your handler.

public class MyButton : View, IMyButton
{
}

Next, create a handler targeting the interface you defined earlier on each platform where you want to create platform-specific behavior. In this sample, you target Apple's UIButton control, which is the native button implementation on iOS.

public partial class MyButtonHandler : ViewHandler<IMyButton, UIButton>
{
}

Because this handler inherits from ViewHandler, you need to implement the CreateNativeView method.

protected override UIButton CreateNativeView()
{
    return new UIButton();
}

You can use this method to override default values and interact with the native control before it's created. That way, you can set a lot of the things that you would previously do in a custom renderer. Additional methods exist to tackle different scenarios, but I won't go into that in this article.

Working with the Mapper

I mentioned the mapper earlier in this article. It's the replacement of the OnElementChanged method in Xamarin.Forms, which makes it responsible for handling property changes in the handler. This is also the place where you can hook into these changes with your custom code. Here's what the property mapper for the IMyButton you created earlier would look like:

public static PropertyMapper<IMyButton,
    MyButtonHandler> MyButtonMapper =
    new PropertyMapper<IMyButton, MyButtonHandler>
        (ViewHandler.ViewMapper)
{
    [nameof(ICustomEntry.Text)] = MapText,
    [nameof(ICustomEntry.TextColor)] = MapTextColor
};

The dictionary maps properties to static methods that you can use to handle the property changes and customize the behavior:

public static void MapText(MyButtonHandler handler, IMyButton button)
{
    handler.NativeView?.SetTitle(button.Text, UIControlState.Normal);
}

The last thing left to do to make this handler provide custom behavior to your button is to register it. As you recall, .NET MAUI doesn't use assembly scanning anymore. You need to manually register the handler on startup. The next section covers how you can do that.

Adopting the .NET Generic Host

Coming from the ASP.NET Core space, you may already be aware of the .NET Generic Host model. It provides a clean way to configure and start up your apps. It does so by standardizing things like configuration, dependency injection, logging and more. The object is commonly referred to as encapsulating all of this as the host, and it's typically configured through the Main method in a Program class. Alternatively, a Startup class can also provide an entry point to configuring the host. This is what the out-of-the-box generic host in ASP.NET Core looks like:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }
    public static IHostBuilder
        CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
}

Because .NET MAUI will also use the .NET Generic Host model, you'll be able to initialize your apps from a single location moving forward. It also provides the ability to configure fonts, services, and third-party libraries from a centralized location. You do this by creating a MauiProgram class with a CreateMauiApp method. Every platform invokes this method automatically when your app initializes.

using Microsoft.Maui;
using Microsoft.Maui.Hosting;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("ComicSans.ttf","ComicSans");
            });

        return builder.Build();
    }
}

The bare minimum this MauiProgram class needs to do is to build and return a MauiApp. The Application class, referenced as App in the UseMauiApp method, is the root object of your application. This defines the window in which it runs when startup has completed. The App is also where you define the starting page of your app.

using Microsoft.Maui;
using Microsoft.Maui.Controls;

public partial class App : Application
{
    public App()
    {
        InitializeComponent();
        MainPage = new MainPage();
    }
}

I covered the new concept of handlers earlier in this article. If you're looking to hook into this new handler architecture, the MauiProgram class is where you register them. You do this by calling the ConfigureMauiHandlers method and calling the AddHandler method on the current collection of handlers.

using Microsoft.Maui;
using Microsoft.Maui.Hosting;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureMauiHandlers(handlers =>
        {
            handlers.AddHandler(typeof(MyEntry),typeof(MyEntryHandler));
        });

        return builder.Build();
    }
}

In this sample, you're applying the MyEntryHandler to all instances of MyEntry. The code in this handler will therefore run against any object of type MyEntry in your mobile app. This is the preferred solution for when you want to target a completely new control with your handler. If all you want to do is change a property on an out-of-the-box control, you can do this straight from the MauiProgram class as well, or really anywhere you know your code will run prior to the control being used.

#if __ANDROID__

Microsoft.Maui.Handlers.ButtonHandler
    .ButtonMapper["MyCustomization"] = (handler, view) =>
        {
            handler.NativeView.SetBackgroundColor(Color.Green);
        };

#endif

This sample uses compiler directives to indicate that the handler code should only run on the Android platform because it uses APIs that are unavailable on other platforms. If you're doing a lot of platform-specific code, you might want to consider using other multi-targeting conventions instead of littering your code with compiler directives. This essentially means separating your platform-specific code into platform-specific files suffixed with the platform name. By using conditional statements in your project file, you can then ensure that only the platform-specific files are included when compiling for those specific platforms.

Using Existing Xamarin.Forms Custom Renderers

If you're looking to migrate an existing Xamarin.Forms app to .NET MAUI, you might already have written custom renderers to handle some of the functionality of your app. These are usable in .NET MAUI without too much adjustment, but it's advisable to port them over to the handler infrastructure. To use a Xamarin.Forms custom renderer, register it in the MauiProgram class.

var builder = MauiApp.CreateBuilder();

#if __ANDROID__

    builder.UseMauiApp<App>()
        .ConfigureMauiHandlers(handlers =>
        {
            handlers.AddCompatibilityRenderer(
                typeof(Microsoft.Maui.Controls.BoxView),
                typeof(MyBoxRenderer));
        });

#endif

return builder;

Using the AddCompatibilityRenderer method, you can hook up a custom renderer to a .NET MAUI control. You need to do this on a per-platform basis, so if you have multiple platforms, you'll need to add the renderer for each platform individually.

Moving Resources to a Single Project

One of the common pet peeves with Xamarin.Forms is the need to copy a lot of similar resources across multiple projects. If, for example, you have a specific image you want to use in your app, you must include it in all the separate platform projects, and preferably provide it in all the different device resolutions you'd like your app to support. Other types of resources, such as fonts and app icons suffer from a similar issue.

The new Single Project feature in .NET MAUI unifies all these resources into a shared head project that can target every supported platform. The .NET MAUI build tasks will then make sure that these resources end up in the right location when compiling down to the platform-specific artifacts. The single project approach will also improve experiences such as editing the app manifest and managing NuGet packages. Figure 4 shows a mockup of what the single project experience could look like in Visual Studio. The same project that also contains your other shared logic will also contain the shared resources.

Figure       4      : The new Single Project experience in Visual Studio
Figure 4 : The new Single Project experience in Visual Studio

A lot of publicly maintained libraries will need to be ported over to .NET MAUI by their creators. .NET Standard libraries without any Xamarin.Forms types will likely work without any updates. Other libraries will need to adopt the new interfaces and types and recompile as .NET 6-compatible NuGets. Some of these have already started this process by releasing early alpha/beta versions of their libraries. If you've ever developed a Xamarin application in the past, you've most likely also used Xamarin.Essentials and/or the Xamarin Community Toolkit.

Essentials now ships as part of .NET MAUI and resides in the Microsoft.Maui.Essentials namespace.

Just like Xamarin.Forms is evolving into .NET MAUI, the Xamarin Community Toolkit is evolving as well and will be known as the .NET MAUI Community Toolkit moving forward. It will still be the fully open-source, community-supported library that it is today, but it's merging with the Windows Community Toolkit, which allows more efficient code sharing and combining engineering efforts across both toolkits. The Xamarin Community Toolkit will also receive service updates on the same public schedule as Xamarin.Forms.

Check out the .NET MAUI Community Toolkit at: https://github.com/CommunityToolkit/Maui

Transitioning Your Existing App to .NET MAUI

Although Microsoft doesn't recommend porting your existing production apps to .NET MAUI right now, providing an upgrade path once .NET MAUI releases has always been a priority. Due to the existing similarities between Xamarin.Forms and .NET MAUI, the migration process can be straightforward. The .NET Upgrade Assistant is a tool that currently exists to help you upgrade from .NET Framework to .NET 5. With the help of an extension on top of the .NET Upgrade Assistant, you're able to automate migrating your Xamarin.Forms projects to a .NET MAUI SDK-style project while also performing some well-known namespace changes in your code. It does so by comparing your project files to what they need to be in order to be compatible with .NET MAUI. The .NET Upgrade Assistant then suggests the steps to take to automatically upgrade and convert your projects. It also maps specific project properties and attributes to their new versions, while stripping out obsoleted ones. By using extensive logging, as shown in Figure 5, you'll be able to know all the steps the tool has taken to upgrade your project. This will also help you debug potential issues during your migration.

Figure       5      : Informational output from the .NET Upgrade Assistant for .NET MAUI
Figure 5 : Informational output from the .NET Upgrade Assistant for .NET MAUI

During the early days of .NET MAUI, there might not yet be adequate support for some of your NuGet packages. The .NET Upgrade Assistant works with analyzers to go through and validate whether these packages can be safely removed or upgraded to a different version.

Although it's not 100% able to upgrade your project, it does take away a lot of the tedious renaming and repeating steps. As a developer, you'll have to upgrade all your dependencies accordingly and manually register any of your compatibility services and renderers. Microsoft has stated that they will try to minimize the effort this takes as much as possible. Additional documentation on the exact process will be made available closer to release.

Check out the .NET Upgrade Assistant at: https://dotnet.microsoft.com/platform/upgrade-assistant

Conclusion

Developers who've worked with Xamarin.Forms in the past will find a lot of things in .NET MAUI to be familiar. The underlying changes to infrastructure, broader platform scope, and overall unification into .NET 6 also make it appealing to people new to the platform. Centralizing a lot more resources and code into the shared library using the single project feature greatly simplifies solution management. Additional performance improvements through using handlers gives the seasoned Xamarin developer something to explore.

Although the version of .NET MAUI in .NET 6 is highly anticipated, it's also only the first version of the platform. I personally expect a lot of additional features coming soon, and best of all; the entire platform is open source. This means that you and everyone else in the .NET ecosystem can contribute to improve and enhance the platform. I'm certainly curious to see what the future holds!

If you want to try out .NET MAUI for yourself, you can check out the GitHub repository (https://github.com/dotnet/maui) and the Microsoft Docs (https://docs.microsoft.com/dotnet/maui/), which already provide content on getting started.