Note: This is a Guest Blog Post by Michael Rumpler. Michael has been a freelance C# developer since 2003, switching from web to mobile in 2014 upon the introduction of Xamarin.Forms.
Let’s first provide a brief introduction to the MR.Gestures Library to explain why it exists and the problem it solves.
When Xamarin.Forms was released in 2014 it only provided TapGestureRecognizer
which had to be added to a GestureRecognizers
collection. This implementation was always a bit of a code-smell for me. It copied the iOS + Android APIs, but iOS and Android (at the time) only used that architecture because Objective-C and Java didn’t support events. However, In .NET and C# we always used event
and ICommand
for these scenarios.
I created the MR.Gestures library to close this gap. It provides event
s and ICommand
s for gestures on each and every control (all View
s, Layout
s and ContentPage
s) which you can leverage to respond to any touch (and mouse) events.
Before MR.Gestures, we would write this in XAML to handle a tapped event on a Label
:
<Label Text="{Binding Text}">
<Label.GestureRecognizers>
<TapGestureRecognizer Tapped="Label_Tapped" />
</Label.GestureRecognizers>
</Label>
But with MR.Gestures, our XAML now looks like this:
<mr:Label Text="{Binding Text}" Tapped="Label_Tapped" />
And, more importantly, there are 17 other touch (and mouse) events which MR.Gestures supports. Each event’s respective EventArgs
also provide more information than Microsoft’s GestureRecognizers
.
Now that the .NET MAUI engineering team has published their release candidate, the time has come to migrate MR.Gestures from Xamarin.Forms to .NET MAUI.
Starting the Migration
We started by creating a new project in Visual Studio using the .NET MAUI Class Library template. The template creates one single project (csproj
) using multi-targeting to target all platforms.
This template also includes a Platforms folder with subfolders for every platform, Android
, iOS
, MacCatalyst
and Windows
. But it does NOT configure the contents of any folder named Android
, iOS
, MacCatalyst
or Windows
to be compiled only on their respective platform. For this we need to copy a few lines from the official .NET MAUI Multi-Targeting documentation to our csproj
file.
I simplified them a bit so that this is enough:
<ItemGroup Condition="!$(TargetFramework.StartsWith('net6.0-android'))">
<Compile Remove="**/*.Android.cs" />
<Compile Remove="**/Android/**/*.cs" />
</ItemGroup>
<ItemGroup Condition="!$(TargetFramework.StartsWith('net6.0-ios')) AND !$(TargetFramework.StartsWith('net6.0-maccatalyst'))">
<Compile Remove="**/*.iOS.cs" />
<Compile Remove="**/iOS/**/*.cs" />
</ItemGroup>
<ItemGroup Condition="!$(TargetFramework.Contains('-windows'))">
<Compile Remove="**/*.Windows.cs" />
<Compile Remove="**/Windows/**/*.cs" />
</ItemGroup>
<ItemGroup>
<None Include="**/*" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder);$(Compile)" />
</ItemGroup>
The first ItemGroup
makes sure that any files which end in .Android.cs
or are in an Android
folder are only compiled if the current TargetFramework
is net6.0-android
.
The second and third do that for iOS, MacCatalyst and Windows respectively.
And the last one is a fix for VS so that the solution explorer still shows all the files which are only compiled on some platforms.
With this configuration completed, let’s start coding!
Migrating The Controls
Migrating my Xamarin.Forms controls to .NET MAUI was easy. I just changed the base class of my types from Xamarin.Forms.*
to Microsoft.Maui.Controls.*
, eg Microsoft.Maui.Controls.Label
. All of the properties and events in those classes stayed the same.
As you can imagine, this is a lot of code. In Xamarin.Forms, MR.Gestures had 34 classes, each with 18 events, commands and command parameters.
This is a lot of repetitive code which I don’t want to write by hand. So I use a T4 template to generate it. When I first created MR.Gestures in 2014, my template had 110 lines and generated 19,000 lines of code. Over time, with additional events, additional controls, and now with .NET MAUI support, I also added a few controls. Now my T4 template has 188 lines and generates 30,000 lines of C#.
Migrating To Handlers
In Xamarin.Forms, each of my cross-platform controls has a Custom Renderer which implements the platform-specific touch handling logic. In the renderers I override OnElementChanged
, OnElementPropertyChanged
, Dispose
and on Android also DispatchTouchEvent
and DispatchGenericMotionEvent
.
In .NET MAUI, Custom Renderers are replaced by Handlers. A Handler still has the same purpose as a Custom Renderer – it synchronizes changes between the cross-platform control and the platform-specific implementation – but the Handler does not inherit from the platform view; the methods from the Custom Renderer which I overrode no longer exist. I needed to find the right place where to call into my methods for the native gesture handling.
The best information I got about how handlers work was in this repo by Javier Suárez. I also recommend looking at the .NET MAUI source code and the .NET MAUI Community Toolkit source code for additional examples of Handlers and their implementation.
I used the same folder structure as .NET MAUI. It has a folder for each control and within that a handler file for every platform.
Keep in mind that this is a single-project with multi-targeting. To share code between the cross-platform code and the platform-specific code, each of these files is implementing a partial class
. The compiled result is a combination of LabelHandler.cs
and the respective platform file, e.g. LabelHandler.Android.cs
. Therefore each platform-specific file can inherit from different platform-specific base classes.
Every handler has a property PlatformView
, but the type of that property differs per platform.
File | Platform | PlatformView |
---|---|---|
LabelHandler.cs | All | |
LabelHandler.Android.cs | Android |
AndroidX.AppCompat.Widget.AppCompatTextView
|
LabelHandler.iOS.cs | iOS & MacCatalyst |
Microsoft.Maui.Platform.MauiLabel which is a UILabel
|
LabelHandler.Windows.cs | Windows |
Microsoft.UI.Xaml.Controls.TextBlock
|
The most important properties in a Handler are PlatformView
, which is the platform-specific implementation of the view on its respective platform, and VirtualView
, which is the cross-platform control that we use to compose a .NET MAUI control. In this example, we are using a Label
.
In my Handlers, I inherit from the respective .NET MAUI Handler, e.g. LabelHandler
, which provides the following methods which I can override:
Handler Method | Purpose |
---|---|
CreatePlatformView
|
Create the platform-specific view |
ConnectHandler
|
Initialize the platform-specific view |
DisconnectHandler
|
Clean up + Dispose |
Note: As of writing this blog post, a bug exists, “DisconnectHandler is never called”, that is scheduled to be fixed in a service release to .NET v6.0.3.
As I wrote above, a Renderer IS A platform-specific view which allows me to easily override, for example, the Android-specific methods DispatchTouchEvent
and DispatchGenericMotionEvent
.
Now I need to create a sub-class of the View
used by the Android handler, override the noted methods, and return a new instance of that class from CreatePlatformView
. It’s a bit more complicated, but still no big deal.
Mapper
When properties change in the cross-platform control, .NET MAUI uses a PropertyMapper
to notify the handler. A PropertyMapper
is basically a Dictionary
which maps the property name to a method that is invoked each time the property changes. Each respective method is called when that particular property changes.
LabelHandler.cs
public partial class LabelHandler : ILabelHandler
{
public static IPropertyMapper<ILabel, ILabelHandler> Mapper = new PropertyMapper<ILabel, ILabelHandler>(ViewHandler.ViewMapper)
{
[nameof(ILabel.Text)] = MapText,
// ...
};
public LabelHandler() : base(Mapper) { }
public LabelHandler(IPropertyMapper? mapper = null) : base(mapper ?? Mapper) { }
}
LabelHandler
defines a static Mapper
which basically states that each time the Text
property changes, the method MapText
is invoked.
Note that the PropertyMapper
constructor also receives a ViewHandler.ViewMapper
. This allows PropertyMapper
s to be chained together. Chaining together PropertyMappers
means that it will not only react when a property defined in Label
changes, but also for any property which is defined in its parent’s Mapper
, e.g. Visibility
.
The MapText
method is defined in the respective platform-specific part of the handler. It has parameters of the generic types used by the Mapper
:
LabelHandler.Android.cs
public partial class LabelHandler
{
public static void MapText(ILabelHandler handler, ILabel label)
{
// handler.PlatformView is a AppCompatTextView
handler.PlatformView?.UpdateTextPlainText(label);
}
// ...
}
LabelHandler.iOS.cs
public partial class LabelHandler
{
public static void MapText(ILabelHandler handler, ILabel label)
{
// handler.PlatformView is a UILabel
handler.PlatformView?.UpdateTextPlainText(label);
}
// ...
}
LabelHandler.Windows.cs
public partial class LabelHandler
{
public static void MapText(ILabelHandler handler, ILabel label)
{
// handler.PlatformView is a TextBlock
handler.PlatformView?.UpdateText(label);
}
// ...
}
Now we can translate the methods we used in Xamarin.Forms Custom Renderers to the methods we’ll use in .NET MAUI Handlers to wire-up our custom code
Xamarin.Forms Custom Renderer | .NET MAUI Handler |
---|---|
OnElementChanged
|
ConnectHandler
|
OnElementPropertyChanged
|
Mapper
|
Dispose
|
DisconnectHandler
|
Dispatch*TouchEvent
|
CreatePlatformView and own sub-class
|
From each of these methods, I call into my native platform code to handle the gestures. That code did not use Xamarin.Forms at all and therefore it did not change.
Registering the Handler
Xamarin.Forms used the ExportRendererAttribute
to link the cross-platform controls to their Renderers. This had a performance problem: each time the app launched, Xamarin.Forms needed to scan every dll
for said attribute. This was very slow and it got even slower for every reference which you added even if the dependency had nothing to do with Xamarin.Forms at all.
In other words, every NuGet Package added to your Xamarin.Forms app, regardless of whether it implemented Custom Renderers or not, would slow down your app’s startup time.
In .NET MAUI, we register handlers in the startup code of our app in MauiProgram.CreateMauiApp()
, specifically in the ConfigureMauiHandlers
extension method:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler<MR.Gestures.Label, LabelHandler>();
});
return builder.Build();
}
In MR.Gestures, I wrote an extension method to register all the MR.Gestures handlers at once, so all you need to do is this:
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureMRGestures(licenseKey);
return builder.Build();
Note: The
licenseKey
is ignored for now. It will be used when MAUI gets to GA.
Supporting Additional Platforms
.NET MAUI adds two new platforms: MacCatalyst and WinUI3.
In theory, MacCatalyst should run the same iOS code. So in theory it should “just work”. But unfortunately I couldn’t test this yet.
For WinUI3 I took my UWP code and almost exclusively just changed the namespaces from Windows.UI
to Microsoft.UI
.
There was just one pitfall because I used Windows.UI.Xaml.Window.Current.CoreWindow
on UWP which returns a Windows.UI.Core.CoreWindow
. In WinUI3 Microsoft.UI.Xaml.Window.Current.CoreWindow
still exists, but it has the UWP type Windows.UI.Core.CoreWindow
. I had to replace CoreWindow
with something else to find the root window of my view.
Conclusion
Altogether I was very pleased with the migration procedure. I just had to learn how Handlers work in-detail and how to wire-up all my code. In total, it took me two weeks from starting to look into how the .NET MAUI source-code works until I published the first prerelease of MR.Gestures for .NET MAUI.
0 comments