Custom Handlers in .NET MAUI: Replacing the Old Renderer Model
Xamarin.Forms custom renderers were powerful but clunky. You'd subclass a renderer, override methods, and deal with lifecycle issues around element changes. MAUI replaces this with the handler architecture — a mapper-based system that's more composable, more performant, and easier to understand.
Handlers vs Renderers: What Changed
In the renderer model, a single class was responsible for creating the native view, mapping all properties from the cross-platform control, and handling the full lifecycle. This created large, monolithic classes that were difficult to extend without subclassing.
Handlers split this into two concerns: a property mapper that maps cross-platform properties to native view updates, and a command mapper for actions. You can modify either mapper without subclassing, which means you can customise existing controls without creating a brand-new handler.
Customising Existing Controls
The most common scenario isn't creating a completely new control — it's tweaking an existing one. For example, removing the underline from an Android Entry field:
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(
"NoUnderline", (handler, view) =>
{
#if ANDROID
handler.PlatformView.BackgroundTintList =
Android.Content.Res.ColorStateList.ValueOf(
Android.Graphics.Color.Transparent);
#endif
});
This appends a mapping to the existing EntryHandler. Every Entry control in your app will have this customisation applied. No subclassing, no new types to register.
If you only want it on specific entries, check a property:
EntryHandler.Mapper.AppendToMapping("NoUnderline", (handler, view) =>
{
#if ANDROID
if (view is BorderlessEntry)
{
handler.PlatformView.BackgroundTintList =
Android.Content.Res.ColorStateList.ValueOf(
Android.Graphics.Color.Transparent);
}
#endif
});
Building a Custom Handler from Scratch
When you need a control that doesn't exist in MAUI, create a custom handler. Let's build a simple video player control.
First, define the cross-platform control:
public class VideoPlayer : View
{
public static readonly BindableProperty SourceProperty =
BindableProperty.Create(nameof(Source), typeof(string),
typeof(VideoPlayer));
public string Source
{
get => (string)GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
public static readonly BindableProperty AutoPlayProperty =
BindableProperty.Create(nameof(AutoPlay), typeof(bool),
typeof(VideoPlayer), false);
public bool AutoPlay
{
get => (bool)GetValue(AutoPlayProperty);
set => SetValue(AutoPlayProperty, value);
}
}
Next, create the handler with its property mapper:
public partial class VideoPlayerHandler : ViewHandler<VideoPlayer, PlatformVideoView>
{
public static IPropertyMapper<VideoPlayer, VideoPlayerHandler> PropertyMapper =
new PropertyMapper<VideoPlayer, VideoPlayerHandler>(ViewMapper)
{
[nameof(VideoPlayer.Source)] = MapSource,
[nameof(VideoPlayer.AutoPlay)] = MapAutoPlay,
};
public VideoPlayerHandler() : base(PropertyMapper) { }
}
PlatformVideoView is a placeholder — you'll define it differently for each platform using partial classes:
// Platforms/Android/Handlers/VideoPlayerHandler.cs
public partial class VideoPlayerHandler
{
protected override PlatformVideoView CreatePlatformView()
{
return new PlatformVideoView(Context);
}
private static void MapSource(VideoPlayerHandler handler,
VideoPlayer player)
{
handler.PlatformView.SetVideoPath(player.Source);
}
private static void MapAutoPlay(VideoPlayerHandler handler,
VideoPlayer player)
{
if (player.AutoPlay)
handler.PlatformView.Start();
}
}
Registering the Handler
Register your handler in MauiProgram.cs:
builder
.UseMauiApp<App>()
.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler<VideoPlayer, VideoPlayerHandler>();
});
The Command Mapper
Beyond properties, handlers support commands — actions triggered from the cross-platform control:
public static CommandMapper<VideoPlayer, VideoPlayerHandler> CommandMapper =
new(ViewCommandMapper)
{
[nameof(VideoPlayer.Play)] = MapPlay,
[nameof(VideoPlayer.Pause)] = MapPause,
};
private static void MapPlay(VideoPlayerHandler handler,
VideoPlayer player, object? args)
{
handler.PlatformView.Start();
}
Trigger commands from the cross-platform control:
public class VideoPlayer : View
{
public void Play()
{
Handler?.Invoke(nameof(Play));
}
public void Pause()
{
Handler?.Invoke(nameof(Pause));
}
}
Handler Lifecycle
Handlers have a clean lifecycle:
CreatePlatformView()— creates the native viewConnectHandler()— subscribe to native eventsDisconnectHandler()— unsubscribe and clean up
protected override void ConnectHandler(PlatformVideoView platformView)
{
base.ConnectHandler(platformView);
platformView.PlaybackCompleted += OnPlaybackCompleted;
}
protected override void DisconnectHandler(PlatformVideoView platformView)
{
platformView.PlaybackCompleted -= OnPlaybackCompleted;
base.DisconnectHandler(platformView);
}
Practical Tips
Start with mapper modifications. Most customisations don't need a full custom handler. AppendToMapping and PrependToMapping on existing handlers cover the majority of cases.
Use ModifyMapping for overrides. If you want to replace the default mapping behaviour rather than add to it, use ModifyMapping — it gives you access to the original mapping action so you can call it conditionally.
Keep handlers thin. The handler should translate between cross-platform properties and native view calls. Business logic belongs in your view models, not handlers.
The handler architecture is a genuine improvement over renderers. It's more modular, easier to test, and you can customise controls at any level without the all-or-nothing subclassing that renderers required.