Custom renderers are the part of a Xamarin.Forms app that makes people nervous about .NET MAUI, and usually for the wrong reason. The count is frightening — forty renderers in a mature app is normal — but most of them do one small thing, and a meaningful fraction do something that is now built in.
This is the conversion we actually run, with the decision points in the order you meet them.
First: decide whether to convert it at all
.NET MAUI ships a compatibility layer that can host Xamarin.Forms renderers largely unchanged. That is not a workaround to be ashamed of, it is a sequencing tool. Standing the whole app up on the compatibility layer first separates "does it build and launch on .NET MAUI" from "is every custom control right", and those are two different weeks of work.
So the first pass over the renderer list is not a conversion. It is a classification:
- Delete. The behaviour is now built into the .NET MAUI control. Entry border removal, placeholder colours, and a surprising number of one-property renderers fall here. Check before you port.
- Keep on compatibility, for now. It works, it is not on the critical path, and converting it is a later slice.
- Convert. It is on many screens, it touches platform APIs you will have to move anyway, or it is already broken.
Only the third group is this post.
The shape of the change
A Xamarin.Forms renderer is a class per platform that inherits from a
platform-specific renderer base, owns the native control, and reacts to changes
through OnElementChanged and OnElementPropertyChanged. It is
inheritance-based, and it is where the "I have to subclass the renderer to
change one colour" problem comes from.
A .NET MAUI handler is a class that creates the platform view and maps properties onto it through a static property mapper. The mapper is a dictionary from property name to an action, and it is the whole conceptual change: you no longer subclass a renderer to intercept one property, you add an entry to a mapper — sometimes without writing a handler at all.
Step 1: separate the cross-platform contract
Before touching platform code, look at what the renderer's control actually exposes. In a Xamarin.Forms app this is usually a subclass of a control with a few bindable properties:
public class BadgedEntry : Entry
{
public static readonly BindableProperty BadgeTextProperty =
BindableProperty.Create(nameof(BadgeText), typeof(string), typeof(BadgedEntry), null);
public string BadgeText
{
get => (string)GetValue(BadgeTextProperty);
set => SetValue(BadgeTextProperty, value);
}
}
This part moves almost verbatim. BindableProperty still exists, the
namespace changes, and the semantics are the same. Get the control class
compiling on its own before you write a single line of handler code.
Step 2: write the handler skeleton
The handler is generic over the cross-platform type and the platform type, and declares its mapper:
public partial class BadgedEntryHandler : ViewHandler<BadgedEntry, PlatformBadgedEntry>
{
public static IPropertyMapper<BadgedEntry, BadgedEntryHandler> Mapper =
new PropertyMapper<BadgedEntry, BadgedEntryHandler>(ViewHandler.ViewMapper)
{
[nameof(BadgedEntry.BadgeText)] = MapBadgeText,
};
public BadgedEntryHandler() : base(Mapper) { }
}
Two things to notice. The mapper chains from ViewMapper, so everything the
base view already maps keeps working — you are adding, not replacing. And
MapBadgeText is a static method taking (handler, view), which means the
mapping logic is testable without instantiating a platform control.
Step 3: the platform partials
Each platform gets a partial class supplying CreatePlatformView and the map
methods. This is where the old OnElementChanged body goes — specifically,
the part of it that ran when the native control was first created:
// Platforms/Android/BadgedEntryHandler.Android.cs
public partial class BadgedEntryHandler
{
protected override PlatformBadgedEntry CreatePlatformView() =>
new PlatformBadgedEntry(Context);
static void MapBadgeText(BadgedEntryHandler handler, BadgedEntry view) =>
handler.PlatformView.SetBadge(view.BadgeText);
}
The old OnElementPropertyChanged if-ladder disappears entirely. Each
branch of it becomes a map method. In practice this is the step where renderers
get shorter, because the ladder was usually handling properties the framework
now maps for you.
Step 4: connect, disconnect, and the leak
ConnectHandler and DisconnectHandler replace the OnElementChanged
null-check dance for event subscriptions:
protected override void ConnectHandler(PlatformBadgedEntry platformView)
{
base.ConnectHandler(platformView);
platformView.BadgeTapped += OnBadgeTapped;
}
protected override void DisconnectHandler(PlatformBadgedEntry platformView)
{
platformView.BadgeTapped -= OnBadgeTapped;
base.DisconnectHandler(platformView);
}
One thing to know: DisconnectHandler is not called automatically in every
case the way renderer disposal was. If your renderer relied on Dispose to
unsubscribe from a long-lived service, verify the unsubscribe actually happens
— a handler that stays subscribed to a singleton is the most common leak we
find after a conversion, and it shows up as a slow memory climb on a list
screen rather than as a crash.
Step 5: register it
builder.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler<BadgedEntry, BadgedEntryHandler>();
});
If you skip this, the control silently falls back to the base handler and renders without your customisation. It looks like the mapper is not firing.
The case for not writing a handler at all
A large share of Xamarin.Forms renderers exist to change one property on a stock control. In .NET MAUI you can append to the existing mapper globally, with no handler and no control subclass:
EntryHandler.Mapper.AppendToMapping("NoUnderline", (handler, view) =>
{
#if ANDROID
handler.PlatformView.BackgroundTintList =
ColorStateList.ValueOf(Colors.Transparent.ToPlatform());
#endif
});
When we audit a renderer list, this pattern usually accounts for a third of it. Those are not conversions; they are deletions with a few lines of startup code in exchange.
What tends to go wrong
Measurement. Renderers that overrode measurement are the ones that behave differently after conversion. Handler layout has its own measure path, and a control that was sized by a renderer override will need that logic re-expressed rather than pasted.
Order of mapping. Mapper entries run in the order they are added, and a property that depends on another being set first will misbehave if you add it before the one it depends on.
Platform view lifetime. Renderers held the native control for the life of the element. Handlers can be disconnected and reconnected. Any state you kept on the native control needs to live on the cross-platform control instead.
Verifying the conversion
Convert one control, then compare it against the production app on a real screen and a real device, at the smallest and largest system font sizes. Font scaling is where handler measurement differences appear first, and it is not something a simulator screenshot at default settings will show you.
If you are working through a list of these, we do this in releasable slices, highest-traffic controls first, so that a conversion that turns out to be wrong costs one slice rather than a release.