Being a developer (pardon, software engineer) is hard work. If all you had to do was create cool and amazing software, life would be great! But the problem is that everyone else is always creating new stuff as well—including the developers of the software frameworks that strive to make your life easier. At some point, the great thing you were using as the foundation for your product gets long in the tooth and the primary maintainers aren't offering further support unless you upgrade to their latest creations. When that upgrade comes with a painful migration...ugh. Software engineering is hard!
Good or bad, this is where every Xamarin developer finds themselves right about now. Microsoft officially ended support for Xamarin on May 1, 2024 and isn't releasing any further updates or tooling support for it. Pretty soon, apps built with the Xamarin toolchain will stop compiling for Mac and iOS devices due to the way Apple forces developers to upgrade to the latest Xcode version. On the Android front, things are a little rosier, but even then, Xamarin won't support any of the new APIs available in Android 15 due out this Fall. And if you find a bug...oh well! What's a developer to do?
According to Microsoft, the answer is .NET MAUI. Over the past couple of years, folks in Redmond have been developing the successor to Xamarin in an attempt to better unify the overall .NET developer platform. While this is a great goal, it's caused quite the headache for Xamarin developers because feature-parity between Xamarin and MAUI has been a bit slow on the uptake.
But if you're a Xamarin engineer, you already know all of this, right? So what's my point?
Sailing / flying / swimming to MAUI (yes, these are bad jokes—blame Microsoft)
Last year, Rownd developed one of the best authentication SDKs Xamarin has ever seen. It's smooth, fast, and exceptionally user and developer-friendly. We did this because we have customers that depend on Rownd being there for them, no matter which ecosystem they're working in.
Well, since Xamarin is so last year, and MAUI is the new hotness, we couldn't very well sit idly by and let the .NET ecosystem suffer with sub-par authentication software. No sir! Not on our watch. So this year, we've been hard at work porting our Xamarin SDK to .NET MAUI. That's good news for our customers, but at the same time, we learned a bunch of stuff along the way! We wanted to give back to the community by sharing some tips and tricks as you lovely developers port your own apps and libraries over to MAUI.
Ok, ready? Let's dive in.
(Sorry, sorry--one more thing. We did all of this on a Mac. A bunch of it applies to Windows too, but...YMMV.)
Create a new repo
Xamarin and MAUI have a lot of similarities, but the ecosystem is different enough that you'll probably want to separate your code and make a clean break from good-old Xam. We chose to keep all of our git commit history, but push all of our new changes to a brand new repository. A nice side-effect of this is that you can keep patching the old version if needed while working on the new version in a separate folder. No back-and-forth between branches.
Use the migration CLI
This one is definitely another "YMMV" type of situation, but running Microsoft's migration tools can save you a bunch of manual effort. We were able to pretty successfully use the migration tools on our shared libraries. The migration tool updated all of the project structures automatically and bumped us up to .NET 8 frameworks in the process.
The platform-specific projects we had were another story entirely, though. While the migrator asked all of the right questions and pretended like it was going to go do some cool stuff, it ultimately didn't do anything. We had to migrate the platform-specific stuff by hand. Which brings me to...
Migrate to one single, multi-platform project
One thing migration tools didn't do is add the right stuff to get us to a single-project, multi-platform library. This is a huge advantage with MAUI, because it just reduces the number of moving parts. Managing projects is annoying, so manage as few as you can.
Once you migrate your primary shared library, you can paste this XML snippet into the project file to support building platform-specific code in the "/Platforms/<platform>" folder structure.
<!-- Android -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net')) == true AND $(TargetFramework.Contains('-android')) != true">
<Compile Remove="**\Android\**\*.cs" />
<None Include="**\Android\**\*.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
<!-- Both iOS and Mac Catalyst -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net8.0-ios')) != true AND $(TargetFramework.StartsWith('net8.0-maccatalyst')) != true">
<Compile Remove="**\MaciOS\**\*.cs" />
</ItemGroup>
<!-- iOS -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net8.0-ios')) != true">
<Compile Remove="**\iOS\**\*.cs" />
<None Include="**\iOS\**\*.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
<!-- Mac Catalyst -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net8.0-maccatalyst')) != true">
<Compile Remove="**\MacCatalyst\**\*.cs" />
<None Include="**\MacCatalyst\**\*.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
<!-- Windows -->
<ItemGroup Condition="$(TargetFramework.Contains('-windows')) != true">
<Compile Remove="**\Windows\**\*.cs" />
<None Include="**\Windows\**\*.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
Once you've got this in place, copy your code and resource files from your old platform-specific Xamarin projects into this new folder structure. At this point, you'll probably have to update a bunch of "using" statements and go through the code to fix various compile errors. Fix the ones that are straightforward, and save the more complex ones for a bit later.
Also note that if you want to build for Mac Catalyst, you'll probably have to copy the iOS-specific files into the MacCatalyst platform folder. Unfortunately, this seems like an anti-DRY pattern, because now there are multiple, identical files to keep updated when making changes. 😬 There is a "MaciOS" folder you can use for shared Apple-platform code, but it doesn't seem to be possible to use that for things like ViewHandlers or partial classes, which we'll get to in a second. (This Stack Overflow post provides better detail about how to share code more efficiently.)
Migrate custom renderers to view handlers
This is possibly one of the trickiest aspects of the .NET MAUI transition. Xamarin custom renderers are no more. Instead, you have to implement something called a "view handler" to bridge your shared code with your platform-specific code.
Microsoft did this primarily for performance reasons. In Xamarin, the runtime had to scan the assembly during startup in order to properly load platform-specific code, like injected dependencies/services and custom renderers. Apparently, this is slow.
In MAUI, developers have to programmatically register their view handlers during app initialization. If you're an app developer, probably not a huge deal. if you're a library maintainer that needs this, it's pretty annoying because it's yet another thing anyone consuming your library has to deal with. I'm not sure why Microsoft chose this approach. Android has been generating code behind the scenes for ages that would make the dev experience so much better here.
Anyway, Microsoft provides some documentation around this topic, but there are so many angles to it, it's hard to figure it out in one go. Here are a few tips to speed you on your way.
- Get to know partial classes. Love them or hate them, you'll probably need to get familiar with partial classes to bridge shared and platform-specific code. For example, if you need to detect when the keyboard opens on iOS and Android, you'd first create a partial class in your shared code that implements any shared methods, and then define partial methods that will be implemented in platform-specific files.
Partial classes aren't necessarily difficult to deal with, they're a little weird. The compiler smashes together each partial class in the same namespace to create the whole class. Clever? Maybe. Strange? Definitely. Foot gun? 😬 - Use preprocessor directives sparingly. Preprocessor directives (like "#if ANDROID" and "#endif") will likely come into play. It's almost impossible to avoid them—or it takes a good bit more work to do so. Use them where needed, but they really clutter up a codebase, so it's best to use as few as you can reasonably get away with. You'll see one or two in the examples below.
- Put as much as possible into shared code. The less you have to write at the individual platform level, the better. Often, you can capture something happening at the platform level and pass it back to your shared class. By this, you keep the platform-specific code to a minimum and keep your logic more consistent.
Now, consider this simple example where we (over-engineer) a Label to give it a different color depending on which platform it's running on. We'll use an approach that hopefully simplifies the view handler pattern, while teaching you enough to build something more advanced.
First, let's define our view based on the label.
1// Controls/ColoredLabel.cs
2
3namespace MauiMultiPlatformLabelExample.Controls
4{
5 public partial class ColoredLabel : Label
6 {
7 public ColoredLabel()
8 {
9 SetLineBreakMode();
10 SetDefaultColor();
11 }
12
13 private partial void SetDefaultColor();
14
15 private void SetLineBreakMode(LineBreakMode lbr = LineBreakMode.MiddleTruncation)
16 {
17 LineBreakMode = lbr;
18 }
19
20 // Default implementation for platforms not listed here
21#if !ANDROID && !IOS && !MACCATALYST && !WINDOWS
22 private partial void SetDefaultColor() => throw null!;
23#endif
24 }
25}
We've subclassed the label and added a partial method with no implementation. The compiler will be upset about this until we add platform-specific implementations. We'll define one for iOS and Android. "SetDefaultColor()" will be our unique platform implementation point.
// iOS implementation
// Platforms/iOS/Views/TruncatingLabel.iOS.cs (copy this into the MacCatalyst folder as well)
namespace MauiMultiPlatformLabelExample.Controls
{
partial class ColoredLabel
{
private partial void SetDefaultColor()
{
TextColor = Color.FromArgb("#0000ff"); // Blue for iOS
}
}
}
// Android implementation
// Platforms/Android/Views/TruncatingLabel.Android.cs
namespace MauiMultiPlatformLabelExample.Controls
{
partial class ColoredLabel
{
private partial void SetDefaultColor()
{
TextColor = Color.FromArgb("#00ff00"); // Green for Android
}
}
}
Note that here we've defined additional partial classes in the same namespace as the original. The compiler will put them together during the build, fleshing out the implementation.
Of course, we're not doing anything super platform-specific here, but you have access to all of the Apple and Android APIs defined in C#, so you could conceivably do very advanced things. In this case, we're just setting a default color per-platform.
Speaking of which, Android and iOS have different implementations of a "label," so we need to tell the runtime how to render this component. This is where ViewHandlers come into play. While they can get somewhat complex with property and command mappers, let's keep things relatively simple here.
Take a look at our platform-specific handler code below. In this case, we're not providing a shared view handler at all, but we could follow the same "partial class" approach as before if we wanted to share some code across platforms.
// iOS / MacCatalyst implementation
// Platforms/iOS/Controls/ColoredLabelHandler.iOS.cs
namespace MauiMultiPlatformLabelExample.Controls
{
public partial class ColoredLabelHandler : ViewHandler<ColoredLabel, UILabel>
{
public ColoredLabelHandler() : base(ViewHandler.ViewMapper, ViewHandler.ViewCommandMapper) {}
protected override UILabel CreatePlatformView()
{
var platformView = new UILabel();
platformView.Text = VirtualView.Text;
var vtColor = VirtualView.TextColor ?? Color.FromArgb("#000000");
var ptColor = new UIColor(
(nfloat)vtColor.Red,
(nfloat)vtColor.Green,
(nfloat)vtColor.Blue,
(nfloat)vtColor.Alpha
);
platformView.TextColor = ptColor;
return platformView;
}
}
}
// Android implementation
// Platforms/Android/Controls/ColoredLabelHandler.Android.cs
namespace MauiMultiPlatformLabelExample.Controls
{
public partial class ColoredLabelHandler : ViewHandler<ColoredLabel, TextView>
{
public ColoredLabelHandler() : base(ViewHandler.ViewMapper, ViewHandler.ViewCommandMapper) {}
protected override TextView CreatePlatformView()
{
var platformView = new TextView(Context);
platformView.Text = VirtualView.Text;
var vtColor = VirtualView.TextColor ?? Color.FromArgb("#000000");
var ptColor = new Android.Graphics.Color(
(byte)(vtColor.Red * 255),
(byte)(vtColor.Green * 255),
(byte)(vtColor.Blue * 255),
(byte)(vtColor.Alpha * 255)
);
platformView.SetTextColor(ptColor);
return platformView;
}
}
}
In this code, we're conditionally creating a TextView or a UILabel depending on the platform. The .NET C# compiler will grab the right implementation file during the build. In these files, we create a "PlatformView," which is the native platform control that actually renders the element. Already created for us in the base "ViewHandler" class is a "VirtualView" representing MAUI's control surface. If we wanted to make this more useful, we would add a property mapper to pass label properties (like color) from the virtual view to our platform view, applying them in whatever way the underlying platform requires.
Our implementation is pretty much done. There's just one final step. As I mentioned previously, registering the view handler during program startup is critical, otherwise MAUI won't know how to render this thing.
In our MauiProgram.cs file, we'll add a block to configure MAUI handlers, adding our custom handler to the "registry." (Microsoft loves registries.)
Here's a quick example of what it might look like after we finish.
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler(typeof(ColoredLabel), typeof(ColoredLabelHandler));
})
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
return builder.Build();
}
}
This is the especially annoying part for library maintainers, but there's a slight bit of syntactic sugar we can apply to make it a little more bearable. This is especially helpful if we have a bunch of view handlers that need registration.
In your library, add a class that looks like this.
namespace MyLib.Utils
{
public static class MauiConfig
{
public static MauiAppBuilder UseMyLib(this MauiAppBuilder builder)
{
builder.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler(typeof(ColoredLabel), typeof(ColoredLabelHandler));
});
return builder;
}
}
}
Here, we're extending the Maui builder pattern with a custom function in which we can register any handlers we need a consuming app to leverage. Now, our users can add one "using" and one simple statement to their MauiProgram.cs file instead of a bunch of potentially complicated and changing lines.
using MyLib.Utils;
...
builder
.UseMauiApp<App>()
.UseMyLib() // <-- This is all we need now!
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
Update deprecated code—but only when it makes sense
In MAUI, there are two types of deprecated code. The first is code that has essentially been moved to another namespace or requires only a slight shift in approach to migrate to the recommended usage. This would be something like "Device.BeginInvokeOnMainThread()" moving to "MainThread.BeginInvokeOnMainThread()." (Please tell me why it's not just named "MainThread.BeginInvoke()"! 😮💨 The second type is code that has no direct replacement and requires shifting a ton of stuff around to get it working. An example is RelativeLayouts. MAUI deprecated it, but didn't truly replace it one-for-one. Instead they typically recommend Grids, which can be tricky and don't always support everything you could do with a RelativeLayout.
In the first example, migration is easy, so just spend the time to do it. In the second example, migration is much more difficult. Thankfully, MAUI does provide some compatibility libs that you can choose to pull in so that "legacy" controls still work. A RelativeLayout should be usable by just mapping it to the "compatibility" namespace in your XAML.
Overall, you have to weigh the tradeoff. If it takes you weeks to properly migrate one component, that's probably not a great use of time. If it's a day or less, then that's an easier sell.
Ensure MAUI is the right choice
For many apps, the migration from Xamarin to MAUI will be relatively straightforward. That's not to say it will be easy, but because the basic building blocks are similar and Microsoft has provided compatibility layers, it's probably a good idea to stick with the ecosystem.
That said, if you're using a lot of libraries or modules that don't have direct replacements or you've written a lot of device-specific code, you might end up rewriting a lot of components. In that event, it's worth considering whether an alternative multi-platform framework like Flutter or React Native makes sense.
If your app has been successful and you're ready to take its UX and performance to the next level, consider building two separate apps in Swift and Kotlin respectively. Users will always get the best overall experience from apps written in native languages and frameworks. Even then, it's possible to write low-level code to keep the "business logic" of native apps the same across platforms so you aren't building everything twice.
Ultimately, Microsoft has done a lot with MAUI to make a more modern Xamarin. As we sail toward MAUI and Xamarin disappears into the sunset, it remains to be seen whether Microsoft can get the masses to adopt its new framework. Competition is always good, so here's hoping it'll be a roaring success!
If you're deep into MAUI, we'd love to hear more about what you're building! And if you need authentication for your app, grab our MAUI SDK and give it a try! We're here to help with any issues and we're always listening to customer feedback to help us improve the experience.
Now, if you'll excuse me, I have some bags to pack. There's an island in the Pacific calling my name! 😉