The Pitfalls of Migrating from Xamarin.Forms to .NET MAUI
DEV Community

The Pitfalls of Migrating from Xamarin.Forms to .NET MAUI

Why the migration is not a simple upgrade On paper, migrating from Xamarin.Forms to .NET MAUI looks like a namespace change. In practice, the time goes into three areas: custom renderers, navigation, and plugins that are no longer maintained. Microsoft's support for Xamarin ended in May 2024, and the apps still on it are getting harder to publish as Apple and Google raise their minimum SDK requirements. The mechanical part is relatively easy. The project moves to the single-project format, Xamarin.Forms becomes Microsoft.Maui.Controls , and Xamarin.Essentials is built directly into the framework. Automated upgrade tools and AI assistants handle this part well. What they don't handle well is understanding why a custom renderer existed, what the code assumed about the order in which pages load, or what hidden behavior an old plugin provided. This article walks through those pitfalls, with code examples and solutions that work in real projects. From renderers to handlers The biggest conceptual change is that MAUI no longer customizes controls through inheritance, but through mappers: dictionaries that link each property of a cross-platform control to the native code that applies it. In Xamarin.Forms, removing the underline from an Entry on Android meant writing an entire renderer: [assembly: ExportRenderer(typeof(BorderlessEntry), typeof(BorderlessEntryRenderer))] public class BorderlessEntryRenderer : EntryRenderer { public BorderlessEntryRenderer(Context context) : base(context) { } protected override void OnElementChanged(ElementChangedEventArgs e) { base.OnElementChanged(e); if (Control != null) Control.Background = null; } } In MAUI, the same effect comes from adding an entry to the existing mapper, usually in MauiProgram.cs : Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("Borderless", (handler, view) => { if (view is not BorderlessEntry) return; #if ANDROID handler.PlatformView.Background = null; #elif IOS || MACCATALYST handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None; #endif }); Pitfall 1: mappers are global A renderer applied only to the type registered with ExportRenderer . An entry added to EntryHandler.Mapper applies to every Entry in the app. The if (view is not BorderlessEntry) return; check in the example above is not optional. Without it, every form in the app loses its border, and the bug only shows up during testing, on screens you weren't looking at. Pitfall 2: OnElementChanged has no direct equivalent Old renderers mixed three things in OnElementChanged : creating the native control, subscribing to events, and unsubscribing. In MAUI these are separate. For a custom handler, the native control is created in CreatePlatformView , subscriptions go in ConnectHandler , and cleanup goes in DisconnectHandler . Moving the old code without splitting it across these three methods usually leads to memory leaks or events that fire twice. Also watch when DisconnectHandler gets called. In early MAUI versions it had to be called manually, while newer versions have a configurable policy via HandlerProperties.DisconnectPolicy . Check the documentation for the .NET version you're using. Pitfall 3: the temptation of compatibility renderers MAUI lets you register old renderers through AddCompatibilityRenderer . That's useful for getting the app running quickly, but it's technical debt: the compatibility package is marked obsolete and doesn't benefit from the new pipeline's optimizations. My recommendation is to use it only as an intermediate step, with a clear ticket for each remaining renderer. Pitfall 4: the native control type has changed Old code often cast to specific native classes. In MAUI, handler.PlatformView may be a MAUI-derived class (for example MauiTextField on iOS), not the UIKit or Android class you knew. A wrong cast doesn't produce a compile error but an InvalidCastException at runtime, usually on only one platform. Shell and navigation Shell existed in Xamarin.Forms too, but many older apps used NavigationPage , TabbedPage , and MasterDetailPage . In MAUI, Shell becomes the recommended approach, and moving to it changes more than the syntax. Pitfall 5: Shell doesn't accept every page type A ShellContent must contain a ContentPage . A TabbedPage or FlyoutPage (formerly MasterDetailPage ) placed inside Shell is not supported. Tabs and the side menu are defined through the Shell structure (TabBar , Tab , FlyoutItem ). If the app had tabs nested inside detail pages, the structure has to be rethought, not just moved. Pitfall 6: routes and parameters Pages that don't appear in the Shell visual hierarchy must be registered explicitly, otherwise navigation fails at runtime: Routing.RegisterRoute(nameof(PatientDetailPage), typeof(PatientDetailPage)); await Shell.Current.GoToAsync($"{nameof(PatientDetailPage)}?id={patient.Id}"); Two details take the most time. First, the // prefix means an absolute route and resets the navigation stack, which is right after login but wrong in a list-detail flow. Second, query string parameters are text. For complex objects, use the parameter dictionary (or ShellNavigationQueryParameters in recent versions, valid for a single navigation) and receive them through IQueryAttributable , not through manual serialization in the URL. Pitfall 7: page lifecycle With ContentTemplate , Shell creates pages the first time they are shown, not when the app starts. Code that assumed a ViewModel already existed (for example, subscribing in a constructor to messages sent by another page) can miss events. In addition, pages in tabs usually stay in memory, so OnAppearing is called every time you return, not just once. Data-loading logic needs to be idempotent. Pitfall 8: dependency injection in pages MAUI has built-in DI, and Shell can resolve pages and ViewModels from the container if they are registered in MauiProgram.cs . Pay attention to the lifetime you choose: a page registered as a singleton keeps its state between navigations, while a transient one loses it. Code that used DependencyService.Get () still works, but this is a good moment to move it to constructor injection. Plugins that no longer exist In many projects, plugins block the migration more than your own code does. The Xamarin ecosystem was built on community-maintained packages: some were abandoned, some were folded into the framework, and others were rewritten under a different name. The first step is an inventory of every NuGet package, with a replacement for each one. - Xamarin.Essentials - built into MAUI - namespaces change ( Microsoft.Maui.Devices ,Microsoft.Maui.Storage ,Microsoft.Maui.ApplicationModel , etc.) - Plugin.Permissions - Permissions (built in) - similar API, but manifest/Info.plist declarations are still required - Plugin.Connectivity - Connectivity (built in) - Plugin.Media - MediaPicker (built in) - for full camera control, CommunityToolkit.Maui.Camera - Xamarin.CommunityToolkit - CommunityToolkit.Maui - many APIs renamed; check behaviors and converters one by one - Rg.Plugins.Popup - Mopups or Popup from CommunityToolkit.Maui - Mopups keeps a similar API, faster migration - FFImageLoading - standard Image or community forks for MAUI - the original package is no longer maintained - Xamarin.Forms.Maps - Microsoft.Maui.Controls.Maps - requires UseMauiMaps() inMauiProgram.cs - SkiaSharp.Views.Forms - SkiaSharp.Views.Maui.Controls - requires UseSkiaSharp() - Lottie (Xamarin) - SKLottieView from SkiaSharp.Extended.UI.Maui - MessagingCenter -WeakReferenceMessenger from CommunityToolkit.Mvvm -MessagingCenter is marked obsolete - Visual Studio App Center - Sentry, Firebase Crashlytics, etc. - App Center was retired in March 2025 Pitfall 9: the plugin “works”, but only on one platform Some packages have versions that install without errors in a MAUI project but cover only some platforms or depend on old native libraries. The build passes, and the problem appears in the iOS build, at publishing time, or on the first run on a real device. Check on NuGet which frameworks each package targets (for example net8.0-android , net8.0-ios ) and the date of its latest release. Pitfall 10: hidden behaviors Old plugins often did things you couldn't see in your code. Plugin.Media, for example, requested permissions on its own and could compress or rotate the image. MediaPicker doesn't do all of that, so after migration you may get photos several MB in size or with the wrong orientation. Every replacement has to be tested for behavior, not just for compilation. Small pitfalls that eat up time None of the issues below is hard, but together they can add whole days to an estimate. - Image resources. In the single project, images live in Resources/Images and must follow Android rules: lowercase letters, digits and underscores, no hyphens. SVG files are converted to PNG at build time and are referenced in XAML with the.png extension. - Layouts. Default spacing values have changed, RelativeLayout has moved to the compatibility package, andFrame is replaced byBorder . Screens look “almost” the same, which is more dangerous than looking obviously broken. - Device APIs. Device.RuntimePlatform becomesDeviceInfo.Platform , andDevice.BeginInvokeOnMainThread becomesMainThread.BeginInvokeOnMainThread orDispatcher . The old code compiles with warnings, so it's easy to ignore. - ListView vs. CollectionView. ListView still exists, butCollectionView is the recommended option and behaves differently for selection, separators and row height. - Compiled bindings. Adding x:DataType in XAML brings a visible performance gain and catches, at compile time, wrong bindings that failed silently in Xamarin. - Trimming and AOT. Release builds can strip code accessed through reflection (serialization, dynamically configured DI). An app that works perfectly in Debug can stop at startup in Release. Test the Release build on a real device in the first week, not at the end. Conclusion: a lower-risk strategy A successful migration st

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.