The Cross-Platform Paradox: Why You Still Need Platform-Specific Code
Here's something nobody warns you about when you start building with .NET MAUI: cross-platform doesn't mean write-once-run-everywhere. Not really. MAUI gives you an incredible abstraction layer that handles maybe 80-90% of your app's needs with shared code. But that remaining 10-20%? That's where the real magic happens — and honestly, where most of the frustration lives too.
Every mobile platform has its quirks. Android demands explicit permission workflows and has lifecycle behaviors that differ fundamentally from iOS. iOS has its own design guidelines, security requirements, and API surfaces that just don't map cleanly to a universal abstraction. Then Windows and macOS throw in their own dimensions on top of all that.
If you pretend these differences don't exist, you end up with an app that technically runs everywhere but feels native nowhere.
The good news? .NET MAUI was designed from the ground up to handle this reality. Unlike Xamarin.Forms — which bolted platform-specific capabilities onto a primarily shared architecture through custom renderers — MAUI bakes platform extensibility into its core through the handler architecture, partial classes, conditional compilation, and a rich set of platform integration APIs. This article is your comprehensive guide to all of these mechanisms: when to use each one, how they fit together, and practical patterns for building apps that feel genuinely native on every platform they target.
Understanding the Handler Architecture: MAUI's Foundation for Platform Customization
If you're coming from Xamarin.Forms, you're probably familiar with custom renderers. Handlers are their successor, and the improvements are significant. The old renderer system wrapped each cross-platform control in a platform-specific parent container, adding visual hierarchy overhead and performance costs. Handlers eliminate that extra layer entirely — they map directly from the cross-platform control to the native platform view.
How Handlers Work Under the Hood
Every .NET MAUI control has an associated handler that's responsible for creating and managing the native platform view. When you place a Button in your MAUI XAML, the framework doesn't render some generic cross-platform button widget. Instead, it delegates to a handler that creates a genuinely native button — MaterialButton on Android, UIButton on iOS, Button on Windows.
The handler architecture has two core mapping mechanisms:
- Property Mappers — Dictionaries that map cross-platform property changes to platform-specific actions. When you set
Button.BackgroundColoron your MAUI control, the property mapper invokes the appropriate platform code to update the native view's background color on Android, iOS, or Windows. - Command Mappers — Similar to property mappers, but they handle commands and actions, letting the cross-platform control send instructions to the native view with optional data payloads.
This architecture provides a clean separation of concerns: the cross-platform control defines what needs to happen, and the handler implements how it happens on each platform.
Customizing Existing Handlers
The most common platform-specific task you'll run into is tweaking how an existing control looks or behaves on a specific platform. MAUI gives you three methods for modifying handler mappings, and the distinction between them matters more than you might think:
// In your MauiProgram.cs or a startup configuration method
// PrependToMapping: Runs BEFORE the default MAUI mapping
// Use when you want to set a baseline that MAUI's defaults might override
Microsoft.Maui.Handlers.EntryHandler.Mapper.PrependToMapping("CustomBorderless", (handler, view) =>
{
#if ANDROID
handler.PlatformView.SetBackgroundColor(Android.Graphics.Color.Transparent);
#elif IOS
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});
// AppendToMapping: Runs AFTER the default MAUI mapping
// Use when you want the final say on a property
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("CustomBorderless", (handler, view) =>
{
#if ANDROID
handler.PlatformView.SetBackgroundColor(Android.Graphics.Color.Transparent);
#elif IOS
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});
// ModifyMapping: Wraps the existing mapping, giving you access to the original action
// Use when you want to conditionally modify behavior
Microsoft.Maui.Handlers.EntryHandler.Mapper.ModifyMapping("Background", (handler, view, action) =>
{
// Call the original mapping first
action?.Invoke(handler, view);
// Then apply your customization
#if ANDROID
if (view is BorderlessEntry)
{
handler.PlatformView.SetBackgroundColor(Android.Graphics.Color.Transparent);
}
#endif
});
The key insight here is scope. PrependToMapping and AppendToMapping affect every instance of that control type throughout your entire application. If you only want to customize specific instances, use ModifyMapping with a type check, or apply customizations at the individual control level.
Creating Custom Handlers from Scratch
When you need a control that doesn't exist in MAUI's built-in set, you'll create a custom handler. It's a multi-step process, but honestly each step is pretty straightforward once you see the pattern:
// Step 1: Define the cross-platform control interface
public interface ICustomRatingBar : IView
{
int MaxRating { get; }
int CurrentRating { get; set; }
Color FilledColor { get; }
Color EmptyColor { get; }
void RatingChanged(int newRating);
}
// Step 2: Implement the cross-platform control
public class CustomRatingBar : View, ICustomRatingBar
{
public static readonly BindableProperty MaxRatingProperty =
BindableProperty.Create(nameof(MaxRating), typeof(int), typeof(CustomRatingBar), 5);
public static readonly BindableProperty CurrentRatingProperty =
BindableProperty.Create(nameof(CurrentRating), typeof(int), typeof(CustomRatingBar), 0,
propertyChanged: (bindable, oldValue, newValue) =>
{
if (bindable is CustomRatingBar ratingBar)
ratingBar.RatingChangedEvent?.Invoke(ratingBar, (int)newValue);
});
public static readonly BindableProperty FilledColorProperty =
BindableProperty.Create(nameof(FilledColor), typeof(Color), typeof(CustomRatingBar), Colors.Gold);
public static readonly BindableProperty EmptyColorProperty =
BindableProperty.Create(nameof(EmptyColor), typeof(Color), typeof(CustomRatingBar), Colors.Gray);
public int MaxRating
{
get => (int)GetValue(MaxRatingProperty);
set => SetValue(MaxRatingProperty, value);
}
public int CurrentRating
{
get => (int)GetValue(CurrentRatingProperty);
set => SetValue(CurrentRatingProperty, value);
}
public Color FilledColor
{
get => (Color)GetValue(FilledColorProperty);
set => SetValue(FilledColorProperty, value);
}
public Color EmptyColor
{
get => (Color)GetValue(EmptyColorProperty);
set => SetValue(EmptyColorProperty, value);
}
public event EventHandler<int>? RatingChangedEvent;
public void RatingChanged(int newRating) => CurrentRating = newRating;
}
// Step 3: Create the handler with property and command mappers
public partial class CustomRatingBarHandler : ViewHandler<ICustomRatingBar, PlatformRatingView>
{
public static IPropertyMapper<ICustomRatingBar, CustomRatingBarHandler> PropertyMapper =
new PropertyMapper<ICustomRatingBar, CustomRatingBarHandler>(ViewMapper)
{
[nameof(ICustomRatingBar.MaxRating)] = MapMaxRating,
[nameof(ICustomRatingBar.CurrentRating)] = MapCurrentRating,
[nameof(ICustomRatingBar.FilledColor)] = MapFilledColor,
[nameof(ICustomRatingBar.EmptyColor)] = MapEmptyColor,
};
public static CommandMapper<ICustomRatingBar, CustomRatingBarHandler> CommandMapper =
new(ViewCommandMapper)
{
[nameof(ICustomRatingBar.RatingChanged)] = MapRatingChanged,
};
public CustomRatingBarHandler() : base(PropertyMapper, CommandMapper) { }
// Platform-specific implementations go in partial class files
}
Notice that PlatformRatingView in the generic parameter — that's a placeholder. The actual platform-specific implementation goes in partial class files under the respective Platforms folders. We'll cover that pattern next.
Partial Classes: The Cleanest Way to Organize Platform Code
Partial classes are arguably the most elegant mechanism .NET MAUI offers for platform-specific code. Instead of littering your shared code with #if directives or creating complex dependency injection arrangements, partial classes let you define the contract in shared code and implement it per-platform in separate files. I've found this pattern to be a real game-changer for keeping codebases maintainable as they grow.
The Folder-Based Approach
.NET MAUI's project structure includes a Platforms folder with subfolders for each target platform. Files placed in these folders are automatically compiled only when building for that platform. This is the foundation for the partial class pattern:
// File: Services/IDeviceOrientationService.cs (shared)
namespace MyApp.Services;
public interface IDeviceOrientationService
{
DeviceOrientation GetOrientation();
void LockOrientation(DeviceOrientation orientation);
}
// File: Services/DeviceOrientationService.cs (shared partial)
namespace MyApp.Services;
public partial class DeviceOrientationService : IDeviceOrientationService
{
public partial DeviceOrientation GetOrientation();
public partial void LockOrientation(DeviceOrientation orientation);
}
// File: Platforms/Android/Services/DeviceOrientationService.cs
using Android.Content;
using Android.Views;
using Android.Runtime;
namespace MyApp.Services;
public partial class DeviceOrientationService
{
public partial DeviceOrientation GetOrientation()
{
var context = Platform.CurrentActivity ?? Platform.AppContext;
var windowManager = context.GetSystemService(Context.WindowService)?.JavaCast<IWindowManager>();
var rotation = windowManager?.DefaultDisplay?.Rotation;
return rotation switch
{
SurfaceOrientation.Rotation0 or SurfaceOrientation.Rotation180 => DeviceOrientation.Portrait,
SurfaceOrientation.Rotation90 or SurfaceOrientation.Rotation270 => DeviceOrientation.Landscape,
_ => DeviceOrientation.Unknown,
};
}
public partial void LockOrientation(DeviceOrientation orientation)
{
var activity = Platform.CurrentActivity;
if (activity is null) return;
activity.RequestedOrientation = orientation switch
{
DeviceOrientation.Portrait => Android.Content.PM.ScreenOrientation.Portrait,
DeviceOrientation.Landscape => Android.Content.PM.ScreenOrientation.Landscape,
_ => Android.Content.PM.ScreenOrientation.Unspecified,
};
}
}
// File: Platforms/iOS/Services/DeviceOrientationService.cs
using UIKit;
namespace MyApp.Services;
public partial class DeviceOrientationService
{
public partial DeviceOrientation GetOrientation()
{
var scene = UIApplication.SharedApplication.ConnectedScenes
.OfType<UIWindowScene>()
.FirstOrDefault();
if (scene is null) return DeviceOrientation.Unknown;
return scene.InterfaceOrientation switch
{
UIInterfaceOrientation.Portrait or
UIInterfaceOrientation.PortraitUpsideDown => DeviceOrientation.Portrait,
UIInterfaceOrientation.LandscapeLeft or
UIInterfaceOrientation.LandscapeRight => DeviceOrientation.Landscape,
_ => DeviceOrientation.Unknown,
};
}
public partial void LockOrientation(DeviceOrientation orientation)
{
// iOS orientation locking requires additional setup
// in your AppDelegate or SceneDelegate
}
}
Register it with dependency injection in MauiProgram.cs and you're done. The consuming code never knows — or needs to know — which platform implementation it's using.
The Filename-Based Approach
There's also an alternative to the folder structure: using filename conventions. You can name your files with platform suffixes like DeviceOrientationService.Android.cs and DeviceOrientationService.iOS.cs, then configure your .csproj to handle multi-targeting:
<!-- In your .csproj file -->
<ItemGroup Condition="$(TargetFramework.Contains('-android'))">
<Compile Include="Services/**/*.Android.cs" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework.Contains('-ios'))">
<Compile Include="Services/**/*.iOS.cs" />
</ItemGroup>
This approach keeps related platform implementations close together in the file explorer, which some developers (myself included) actually prefer. There's no performance difference — it's purely an organizational choice.
Conditional Compilation: Quick and Surgical Platform Tweaks
Sometimes you don't need an entire partial class file for a platform difference. Maybe it's a single line that differs, or a small block of initialization code. That's where conditional compilation shines:
public void ConfigureStatusBar()
{
#if ANDROID
var activity = Platform.CurrentActivity;
if (activity?.Window != null)
{
activity.Window.SetStatusBarColor(Android.Graphics.Color.ParseColor("#1a1a2e"));
activity.Window.DecorView.SystemUiFlags |= Android.Views.SystemUiFlags.LightStatusBar;
}
#elif IOS
// iOS handles status bar appearance through Info.plist
// and UIViewController-based configuration
UIApplication.SharedApplication.SetStatusBarStyle(UIStatusBarStyle.LightContent, true);
#elif WINDOWS
// Windows handles title bar separately through WinUI APIs
var titleBar = App.Current?.Windows[0]?.Handler?.PlatformView as MauiWinUIWindow;
if (titleBar != null)
{
var appWindow = titleBar.GetAppWindow();
if (appWindow.TitleBar is not null)
{
appWindow.TitleBar.BackgroundColor = Windows.UI.Color.FromArgb(255, 26, 26, 46);
}
}
#endif
}
The available compilation symbols in .NET MAUI are:
ANDROID— Android targetsIOS— iOS targetsMACCATALYST— macOS via Mac CatalystWINDOWS— Windows targetsTIZEN— Tizen targets (if enabled)
A word of caution though: conditional compilation is great for small, isolated differences. But when your #if blocks start spanning dozens of lines or appear in multiple places, it's time to refactor to partial classes or a dependency injection-based approach. I've seen codebases where #if directives are scattered across dozens of files and it becomes a maintenance nightmare fast. Don't let that happen to you.
Platform Integration APIs: Accessing Device Capabilities the Right Way
.NET MAUI ships with a rich set of built-in platform integration APIs (the successor to Xamarin.Essentials) that handle the most common device capabilities. Before writing any platform-specific code yourself, check whether MAUI already provides what you need. You might be surprised how much is covered out of the box.
Permissions: The Gatekeeper
Almost every native API interaction starts with permissions. MAUI provides a unified permissions API that works across platforms:
public async Task<bool> RequestCameraPermissionAsync()
{
var status = await Permissions.CheckStatusAsync<Permissions.Camera>();
if (status == PermissionStatus.Granted)
return true;
if (status == PermissionStatus.Denied && DeviceInfo.Platform == DevicePlatform.iOS)
{
// On iOS, once denied, you must direct the user to Settings
await Shell.Current.DisplayAlert(
"Camera Permission Required",
"Please enable camera access in Settings to use this feature.",
"OK");
AppInfo.ShowSettingsUI();
return false;
}
if (Permissions.ShouldShowRationale<Permissions.Camera>())
{
await Shell.Current.DisplayAlert(
"Camera Needed",
"We need camera access to scan QR codes and take photos for your profile.",
"OK");
}
status = await Permissions.RequestAsync<Permissions.Camera>();
return status == PermissionStatus.Granted;
}
Don't forget the platform-specific declarations — this trips people up constantly. On Android, you need entries in AndroidManifest.xml. On iOS, you need usage description strings in Info.plist:
<!-- Android: Platforms/Android/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- iOS: Platforms/iOS/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to scan QR codes and capture photos.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs your location to show nearby results.</string>
Geolocation: More Than Just Latitude and Longitude
The Geolocation API is straightforward to use, but has a few nuances worth knowing about:
public async Task<LocationResult> GetCurrentLocationAsync()
{
try
{
var request = new GeolocationRequest(GeolocationAccuracy.High, TimeSpan.FromSeconds(10));
var location = await Geolocation.Default.GetLocationAsync(request);
if (location is null)
{
// Fall back to last known location
location = await Geolocation.Default.GetLastKnownLocationAsync();
}
if (location is not null)
{
return new LocationResult
{
Latitude = location.Latitude,
Longitude = location.Longitude,
Accuracy = location.Accuracy,
IsReduced = location.ReducedAccuracy, // iOS 14+ reduced accuracy
Timestamp = location.Timestamp,
};
}
return LocationResult.Unavailable;
}
catch (FeatureNotSupportedException)
{
// GPS not available on this device
return LocationResult.NotSupported;
}
catch (PermissionException)
{
// Permission was denied
return LocationResult.PermissionDenied;
}
}
Here's something that catches a lot of developers off guard: starting with iOS 14, users can grant "reduced accuracy" location permission, which gives you an approximate location rather than precise coordinates. The ReducedAccuracy property on the Location object tells you when this is the case. Plan your UX accordingly — if your feature requires precise location (like turn-by-turn navigation), you'll need to prompt the user for full accuracy.
Media Picker: Capturing and Selecting Photos
The MediaPicker API handles both taking photos with the camera and selecting existing images from the gallery:
public async Task<string?> CapturePhotoAsync()
{
if (!MediaPicker.Default.IsCaptureSupported)
{
await Shell.Current.DisplayAlert("Not Supported",
"Photo capture is not supported on this device.", "OK");
return null;
}
var photo = await MediaPicker.Default.CapturePhotoAsync(new MediaPickerOptions
{
Title = "Take a photo"
});
if (photo is null) return null;
// Save to app's local storage
var localPath = Path.Combine(FileSystem.AppDataDirectory, photo.FileName);
using var sourceStream = await photo.OpenReadAsync();
using var destinationStream = File.OpenWrite(localPath);
await sourceStream.CopyToAsync(destinationStream);
return localPath;
}
public async Task<string?> PickPhotoAsync()
{
var photo = await MediaPicker.Default.PickPhotoAsync(new MediaPickerOptions
{
Title = "Select a photo"
});
if (photo is null) return null;
var localPath = Path.Combine(FileSystem.AppDataDirectory, photo.FileName);
using var sourceStream = await photo.OpenReadAsync();
using var destinationStream = File.OpenWrite(localPath);
await sourceStream.CopyToAsync(destinationStream);
return localPath;
}
An important caveat: all media picker methods must be called from the UI thread. MAUI handles permission checks automatically, but the actual operation is interactive and requires UI thread access.
Implementing Biometric Authentication
.NET MAUI doesn't ship with a built-in biometric authentication API — this is one area where you have to roll up your sleeves and reach into platform-specific code (or use a community plugin). Let's walk through implementing it using the partial class pattern with platform-specific implementations:
// Shared: Services/IBiometricService.cs
public interface IBiometricService
{
Task<bool> IsAvailableAsync();
Task<BiometricResult> AuthenticateAsync(string reason);
}
public record BiometricResult(bool Success, string? ErrorMessage = null);
// Shared: Services/BiometricService.cs
public partial class BiometricService : IBiometricService
{
public partial Task<bool> IsAvailableAsync();
public partial Task<BiometricResult> AuthenticateAsync(string reason);
}
// Platforms/Android/Services/BiometricService.cs
using AndroidX.Biometric;
using Android.OS;
using Java.Util.Concurrent;
namespace MyApp.Services;
public partial class BiometricService
{
public partial async Task<bool> IsAvailableAsync()
{
var context = Platform.AppContext;
var biometricManager = BiometricManager.From(context);
var result = biometricManager.CanAuthenticate(
BiometricManager.Authenticators.BiometricStrong |
BiometricManager.Authenticators.BiometricWeak);
return result == BiometricManager.BiometricSuccess;
}
public partial async Task<BiometricResult> AuthenticateAsync(string reason)
{
var tcs = new TaskCompletionSource<BiometricResult>();
var activity = Platform.CurrentActivity;
if (activity is null)
return new BiometricResult(false, "No active activity");
var executor = Executors.NewSingleThreadExecutor()!;
var callback = new BiometricCallback(tcs);
var promptInfo = new BiometricPrompt.PromptInfo.Builder()
.SetTitle("Authentication Required")
.SetSubtitle(reason)
.SetNegativeButtonText("Cancel")
.SetAllowedAuthenticators(
BiometricManager.Authenticators.BiometricStrong |
BiometricManager.Authenticators.BiometricWeak)
.Build();
MainThread.BeginInvokeOnMainThread(() =>
{
var biometricPrompt = new BiometricPrompt(
(AndroidX.Fragment.App.FragmentActivity)activity,
executor,
callback);
biometricPrompt.Authenticate(promptInfo);
});
return await tcs.Task;
}
}
internal class BiometricCallback : BiometricPrompt.AuthenticationCallback
{
private readonly TaskCompletionSource<BiometricResult> _tcs;
public BiometricCallback(TaskCompletionSource<BiometricResult> tcs) => _tcs = tcs;
public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result)
=> _tcs.TrySetResult(new BiometricResult(true));
public override void OnAuthenticationFailed()
=> _tcs.TrySetResult(new BiometricResult(false, "Authentication failed"));
public override void OnAuthenticationError(int errorCode, Java.Lang.ICharSequence errString)
=> _tcs.TrySetResult(new BiometricResult(false, errString?.ToString() ?? "Unknown error"));
}
// Platforms/iOS/Services/BiometricService.cs
using LocalAuthentication;
using Foundation;
namespace MyApp.Services;
public partial class BiometricService
{
public partial async Task<bool> IsAvailableAsync()
{
var context = new LAContext();
return context.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthenticationWithBiometrics, out _);
}
public partial async Task<BiometricResult> AuthenticateAsync(string reason)
{
var context = new LAContext();
if (!context.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthenticationWithBiometrics, out var canEvalError))
{
return new BiometricResult(false, canEvalError?.LocalizedDescription ?? "Biometrics not available");
}
var (success, error) = await context.EvaluatePolicyAsync(
LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
reason);
return success
? new BiometricResult(true)
: new BiometricResult(false, error?.LocalizedDescription ?? "Authentication failed");
}
}
Register the service in MauiProgram.cs:
builder.Services.AddSingleton<IBiometricService, BiometricService>();
This pattern is clean, testable, and follows the same dependency injection approach used throughout MAUI. Your view models interact with IBiometricService without knowing anything about the underlying platform implementation. That's exactly how it should be.
Implementing Local Notifications Across Platforms
Local notifications are another feature that requires platform-specific implementation. While there are community plugins like Plugin.LocalNotification that can save you some time, understanding the underlying mechanisms helps you make better architectural decisions — and debug issues when they (inevitably) pop up.
// Shared interface
public interface INotificationService
{
Task<bool> RequestPermissionAsync();
Task ScheduleNotificationAsync(string title, string body, DateTime scheduledTime);
Task CancelAllAsync();
}
On Android, you'll work with NotificationManager and notification channels (required since Android 8.0):
// Platforms/Android/Services/NotificationService.cs
using Android.App;
using Android.Content;
using AndroidX.Core.App;
namespace MyApp.Services;
public partial class NotificationService
{
private const string ChannelId = "myapp_default";
public partial async Task<bool> RequestPermissionAsync()
{
if (OperatingSystem.IsAndroidVersionAtLeast(33))
{
var status = await Permissions.RequestAsync<Permissions.PostNotifications>();
return status == PermissionStatus.Granted;
}
return true; // Pre-Android 13 doesn't require runtime permission
}
public partial async Task ScheduleNotificationAsync(string title, string body, DateTime scheduledTime)
{
EnsureChannelCreated();
var context = Platform.AppContext;
var intent = new Intent(context, typeof(NotificationReceiver));
intent.PutExtra("title", title);
intent.PutExtra("body", body);
var pendingIntent = PendingIntent.GetBroadcast(
context, 0, intent,
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
var alarmManager = (AlarmManager?)context.GetSystemService(Context.AlarmService);
var triggerTime = (long)(scheduledTime.ToUniversalTime() - DateTime.UnixEpoch).TotalMilliseconds;
alarmManager?.SetExactAndAllowWhileIdle(AlarmType.RtcWakeup, triggerTime, pendingIntent);
}
private void EnsureChannelCreated()
{
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;
var channel = new NotificationChannel(ChannelId, "Default Notifications",
NotificationImportance.High)
{
Description = "Default notification channel for app alerts"
};
var notificationManager = (NotificationManager?)Platform.AppContext
.GetSystemService(Context.NotificationService);
notificationManager?.CreateNotificationChannel(channel);
}
public partial async Task CancelAllAsync()
{
var notificationManager = (NotificationManager?)Platform.AppContext
.GetSystemService(Context.NotificationService);
notificationManager?.CancelAll();
}
}
On iOS, the UserNotifications framework handles local notifications:
// Platforms/iOS/Services/NotificationService.cs
using UserNotifications;
namespace MyApp.Services;
public partial class NotificationService
{
public partial async Task<bool> RequestPermissionAsync()
{
var center = UNUserNotificationCenter.Current;
var (granted, _) = await center.RequestAuthorizationAsync(
UNAuthorizationOptions.Alert | UNAuthorizationOptions.Sound | UNAuthorizationOptions.Badge);
return granted;
}
public partial async Task ScheduleNotificationAsync(string title, string body, DateTime scheduledTime)
{
var content = new UNMutableNotificationContent
{
Title = title,
Body = body,
Sound = UNNotificationSound.Default,
};
var delay = scheduledTime - DateTime.UtcNow;
if (delay.TotalSeconds <= 0) delay = TimeSpan.FromSeconds(1);
var trigger = UNTimeIntervalNotificationTrigger.CreateTrigger(delay.TotalSeconds, false);
var request = UNNotificationRequest.FromIdentifier(
Guid.NewGuid().ToString(), content, trigger);
await UNUserNotificationCenter.Current.AddNotificationRequestAsync(request);
}
public partial async Task CancelAllAsync()
{
UNUserNotificationCenter.Current.RemoveAllPendingNotificationRequests();
UNUserNotificationCenter.Current.RemoveAllDeliveredNotifications();
}
}
Native Library Interop: When MAUI Isn't Enough
Sometimes you need to integrate a native SDK that has no .NET wrapper — a payment processor, an analytics library, or maybe a hardware-specific API. .NET MAUI supports native library binding, and the community has streamlined this considerably with the Maui.NativeLibraryInterop toolkit.
The Slim Binding Pattern
Rather than binding every class and method in a native library (which is error-prone and frankly a maintenance headache), the recommended approach is to create a thin native wrapper that exposes only the functionality you actually need:
// For Android: Create a Java/Kotlin wrapper class
// NativeWrapper/src/main/java/com/myapp/AnalyticsWrapper.java
package com.myapp;
import com.analytics.sdk.Analytics;
import com.analytics.sdk.Event;
public class AnalyticsWrapper {
private static Analytics instance;
public static void initialize(android.content.Context context, String apiKey) {
instance = Analytics.getInstance(context);
instance.configure(apiKey);
}
public static void trackEvent(String name, String propertiesJson) {
if (instance == null) return;
Event event = Event.fromJson(name, propertiesJson);
instance.track(event);
}
public static void setUserId(String userId) {
if (instance == null) return;
instance.identify(userId);
}
}
// Then in your .NET MAUI Android platform code:
// Platforms/Android/Services/AnalyticsService.cs
using Java.Interop;
namespace MyApp.Services;
public partial class NativeAnalyticsService
{
public partial void Initialize(string apiKey)
{
Com.Myapp.AnalyticsWrapper.Initialize(Platform.AppContext, apiKey);
}
public partial void TrackEvent(string name, Dictionary<string, string> properties)
{
var json = System.Text.Json.JsonSerializer.Serialize(properties);
Com.Myapp.AnalyticsWrapper.TrackEvent(name, json);
}
public partial void SetUserId(string userId)
{
Com.Myapp.AnalyticsWrapper.SetUserId(userId);
}
}
This "slim binding" approach means you only need to maintain bindings for a handful of methods rather than an entire SDK surface. When the native SDK updates, you adjust your thin wrapper rather than regenerate hundreds of binding definitions. Trust me, future-you will thank present-you for this decision.
Migrating from Xamarin.Forms Custom Renderers
If you're migrating from Xamarin.Forms, you probably have custom renderers that need to be converted to handlers. The conceptual shift is straightforward, but there are specific patterns worth following to make the transition smooth:
// OLD: Xamarin.Forms Custom Renderer
public class CustomEntryRenderer : EntryRenderer
{
protected override void OnElementChanged(ElementChangedEventArgs<Entry> e)
{
base.OnElementChanged(e);
if (Control != null)
{
Control.SetBackgroundColor(Android.Graphics.Color.Transparent);
Control.SetPadding(20, 10, 20, 10);
}
}
protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (e.PropertyName == nameof(Entry.Text))
{
// React to text changes
}
}
}
// NEW: .NET MAUI Handler approach (no custom handler needed for simple cases)
// In MauiProgram.cs:
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("CustomEntry", (handler, view) =>
{
#if ANDROID
handler.PlatformView.SetBackgroundColor(Android.Graphics.Color.Transparent);
handler.PlatformView.SetPadding(20, 10, 20, 10);
#endif
});
Key differences to note:
OnElementChangedis replaced byCreatePlatformView,ConnectHandler, andDisconnectHandlerOnElementPropertyChangedis replaced by individual entries in the property mapperControlbecomesPlatformViewElementbecomesVirtualView- Handlers don't create a wrapping container view, which reduces the visual tree depth
For complex renderers, the compatibility shim lets you use existing Xamarin.Forms renderers in MAUI while you plan a full migration. But don't leave them there permanently — Microsoft hasn't committed to supporting the renderer compatibility layer long-term, so treat it as a bridge, not a destination.
Best Practices for Platform-Specific Code Organization
After working through all these mechanisms, let's distill the decision-making process into practical guidelines you can actually use day-to-day.
When to Use Each Approach
- Built-in Platform Integration APIs — Always check here first. Geolocation, camera, file system, connectivity, sensors, preferences, secure storage — MAUI covers a lot out of the box.
- Handler Customization (AppendToMapping/ModifyMapping) — Use for visual tweaks to existing controls that need to differ per platform. Borderless entries, custom fonts, platform-specific styling.
- Conditional Compilation (#if) — Use for small, isolated differences within otherwise shared code. A couple of lines, not a couple of pages.
- Partial Classes — Use for services or components that have fundamentally different implementations per platform. Biometrics, notifications, hardware integration.
- Custom Handlers — Use when you need entirely new controls that wrap platform-native views not available in MAUI.
- Native Library Binding — Use when integrating third-party native SDKs that don't have .NET wrappers.
Architectural Guidelines
- Interface-first design — Always define a cross-platform interface before implementing platform-specific code. This keeps your view models and business logic platform-agnostic and testable.
- DI registration — Register platform services through
builder.ServicesinMauiProgram.cs. Avoid service locator patterns or static access to platform-specific types. - Test with mocks — Because your platform services implement interfaces, you can mock them in unit tests. Your view model tests shouldn't need a device or emulator to run.
- Keep platform code thin — Platform implementations should be thin wrappers around native APIs. Business logic belongs in shared code that calls these wrappers.
- Handle platform absence gracefully — Not every platform supports every feature. Design your interfaces to communicate capability availability, and build your UI to adapt when features aren't there.
Common Pitfalls to Avoid
- Over-abstracting simple differences — If you have a one-liner that differs per platform, a conditional compilation directive is simpler and more maintainable than a full partial class setup. Don't over-engineer it.
- Forgetting permission declarations — Runtime permission requests won't work without the corresponding platform manifest entries. This is the number one cause of "works on my machine, crashes in production" issues. I can't stress this enough.
- Ignoring lifecycle differences — Android activities can be destroyed and recreated at any time; iOS view controllers have different loading semantics. Your platform code needs to account for these lifecycle differences.
- Global handler modifications —
AppendToMappingandPrependToMappingaffect every instance of a control. UseModifyMappingwith type checks when you only want to customize specific controls. - Blocking the UI thread — Many platform APIs need to run on the main thread, but long-running operations absolutely should not. Use
MainThread.InvokeOnMainThreadAsyncfor UI operations and keep heavy work on background threads.
Putting It All Together: A Real-World Service Layer
So, let's see how all these patterns actually combine in a real application. Here's a simplified service registration that demonstrates the layered approach:
// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Platform-specific services via partial classes
builder.Services.AddSingleton<IBiometricService, BiometricService>();
builder.Services.AddSingleton<INotificationService, NotificationService>();
builder.Services.AddSingleton<IDeviceOrientationService, DeviceOrientationService>();
// Built-in MAUI platform APIs (registered by default)
// Geolocation, MediaPicker, Connectivity, Preferences, SecureStorage
// Handler customizations for platform-specific UI
CustomizeHandlers();
// View models that consume platform services through DI
builder.Services.AddTransient<MainPageViewModel>();
builder.Services.AddTransient<ProfilePageViewModel>();
return builder.Build();
}
private static void CustomizeHandlers()
{
// Remove underline from Entry on Android
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoUnderline", (handler, view) =>
{
#if ANDROID
handler.PlatformView.BackgroundTintList =
Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
#endif
});
// Customize Picker appearance
Microsoft.Maui.Handlers.PickerHandler.Mapper.AppendToMapping("CustomPicker", (handler, view) =>
{
#if IOS
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});
}
// A view model that uses platform services without knowing platform details
public class ProfilePageViewModel : ObservableObject
{
private readonly IBiometricService _biometricService;
private readonly INotificationService _notificationService;
public ProfilePageViewModel(
IBiometricService biometricService,
INotificationService notificationService)
{
_biometricService = biometricService;
_notificationService = notificationService;
}
[RelayCommand]
private async Task AuthenticateAndUpdateProfile()
{
if (!await _biometricService.IsAvailableAsync())
{
await Shell.Current.DisplayAlert("Info",
"Biometric authentication is not available on this device.", "OK");
return;
}
var result = await _biometricService.AuthenticateAsync(
"Verify your identity to update your profile");
if (result.Success)
{
// Proceed with profile update
await UpdateProfileAsync();
await _notificationService.ScheduleNotificationAsync(
"Profile Updated",
"Your profile changes have been saved successfully.",
DateTime.UtcNow.AddSeconds(2));
}
}
}
This architecture scales well. Each concern is separated, each platform implementation is isolated, and the shared code stays clean and testable. Whether you're building a simple utility app or an enterprise-grade mobile solution, these patterns will serve you well.
Wrapping Up
.NET MAUI's platform-specific code story is mature, flexible, and honestly well-designed. The handler architecture gives you precise control over how controls render on each platform. Partial classes let you organize platform implementations cleanly without polluting shared code. Conditional compilation handles the small stuff. And the built-in platform integration APIs cover the common device capabilities out of the box.
The key to mastering all of this? Knowing when to reach for each tool. Start with the highest-level abstraction that solves your problem — a built-in API, a handler customization, a simple #if directive — and only drop down to lower-level mechanisms like custom handlers or native bindings when you genuinely need them. This approach keeps your codebase clean, maintainable, and focused on what makes your app unique rather than plumbing that someone else has already built.
Platform-specific code isn't a failure of the cross-platform promise. It's the mechanism that makes the promise real. By embracing the differences between platforms rather than papering over them, you build apps that feel native everywhere — and that's ultimately what your users actually care about.