Handler .NET MAUI 10: Personalizzazione dei Controlli Nativi (Guida 2026)
Gli Handler di .NET MAUI 10 sono il ponte tra i controlli cross-platform e le view native. Impara a personalizzarli con PropertyMapper, CommandMapper ed esempi iOS/Android.
Gli Handler in .NET MAUI 10 sono le classi che collegano ogni controllo cross-platform (come Entry, Button o CollectionView) al corrispondente controllo nativo (rispettivamente UITextField/EditText, UIButton/AppCompatButton, UICollectionView/RecyclerView). Sostituiscono i vecchi Custom Renderer di Xamarin.Forms e permettono di personalizzare i controlli nativi senza sottoclassare, grazie a un sistema di dizionari chiamati Mapper. In questa guida vedremo cosa succede davvero sotto il cofano quando XAML incontra la piattaforma, con esempi che ho estratto da un porting Xamarin che ho seguito l'anno scorso.
Ogni controllo .NET MAUI ha un Handler che crea la PlatformView nativa e la mantiene sincronizzata con la VirtualView cross-platform tramite un Mapper.
Il PropertyMapper è un Dictionary<string, Action> che si può estendere con AppendToMapping, PrependToMapping o ModifyMapping senza sottoclassare il controllo.
Gli Handler sono globali per tipo: modificare il Mapper di Entry influenza ogni Entry dell'app, quindi usa BindableProperty personalizzate se ti serve una personalizzazione mirata.
I Custom Renderer di Xamarin.Forms possono essere migrati agli Handler tramite AddCompatibilityRenderer, ma la riscrittura in un Handler nativo è quasi sempre più performante.
Gli eventi HandlerChanging e HandlerChanged sono il posto giusto per iscriversi e disiscriversi da eventi nativi, evitando memory leak sui delegate iOS.
Cosa sono gli Handler in .NET MAUI?
Nella documentazione ufficiale, Microsoft definisce l'Handler come «il componente responsabile dell'istanziazione della view nativa sottostante e del mapping tra l'API del controllo cross-platform e l'API della view nativa». Tradotto in pratica: quando scrivi <Entry Text="Ciao" /> nel tuo XAML, il framework istanzia un Microsoft.Maui.Controls.Entry (la VirtualView), che chiede al proprio Handler di creare la controparte nativa. Su iOS quella controparte è un UITextField; su Android è un AppCompatEditText. L'Handler mantiene un riferimento a entrambe le view e agisce da traduttore bidirezionale.
La differenza fondamentale rispetto all'approccio dei vecchi Renderer è che l'Handler non è più il controllo: è un oggetto separato, leggero, che si occupa solo di far comunicare due mondi. Il controllo cross-platform (la VirtualView) rimane puro C# gestito, mentre la view nativa (la PlatformView) vive nel suo runtime di piattaforma. Questa separazione è ciò che ha permesso, in .NET 8 e .NET 9, di ridurre drasticamente il footprint di memoria e i tempi di startup, e in .NET 10 di introdurre il trimming aggressivo senza rompere le personalizzazioni utente.
Un Handler tipico eredita da ViewHandler<TVirtualView, TPlatformView> e vive nel namespace Microsoft.Maui.Handlers. Esistono classi base specializzate come ContentViewHandler, LayoutHandler ed ElementHandler per casi che non richiedono una view visibile.
Handler vs Custom Renderer: perché Microsoft ha cambiato architettura
Chi arriva da Xamarin.Forms ricorda i Custom Renderer come classi che ereditavano da EntryRenderer, ButtonRenderer o ViewRenderer<TView, TNativeView>, e che venivano scoperte a runtime tramite ExportRenderer e Assembly Scanning. Funzionava, ma aveva quattro problemi seri: (1) l'Assembly Scanning obbligava il runtime a riflettere su ogni assembly caricato, rallentando lo startup; (2) i Renderer ereditavano dal controllo nativo e ne subclassavano il comportamento, con tutte le fragilità del caso; (3) subscribersi a eventi nativi richiedeva pattern ripetitivi di OnElementChanged/OnElementPropertyChanged; (4) il coupling con il controllo cross-platform era così stretto che aggiornare Xamarin.Forms rompeva spesso i renderer personalizzati.
Gli Handler risolvono tutti questi problemi con un design a dictionary di azioni. Come spiega la documentazione ufficiale sugli Handler, invece di sottoclassare un renderer per modificare una proprietà, si modifica il Mapper. Il Mapper è un dizionario che associa il nome di una proprietà cross-platform a un'azione da eseguire sulla view nativa. Aggiungere una personalizzazione significa aggiungere una entry al dizionario, non ereditare da nulla.
Il risultato è misurabile: nei benchmark interni di Microsoft, la stessa app Xamarin.Forms convertita a .NET MAUI 10 mostra tempi di rendering della prima schermata inferiori del 30–45% su Android, principalmente grazie all'assenza dell'Assembly Scanning. Per un'analisi approfondita di questi guadagni, consulta la nostra guida all'ottimizzazione delle performance di .NET MAUI.
PropertyMapper e CommandMapper: il cuore del sistema
Ogni Handler espone due dizionari statici pubblici: il PropertyMapper e il CommandMapper. Il PropertyMapper contiene voci del tipo [nameof(Entry.Text)] = MapText, dove MapText è un metodo statico con firma (IEntryHandler handler, IEntry entry) => { ... }. Quando la proprietà Text cambia sulla VirtualView, il framework invoca l'azione corrispondente, che a sua volta aggiorna la PlatformView.
Il CommandMapper serve per istruzioni una tantum che portano dati opzionali (ad esempio «metti il focus» o «invalida il layout»). Non è un ICommand nel senso MVVM del termine, è semplicemente un dizionario di azioni con un payload opzionale. Ecco un esempio minimale di come guardare il PropertyMapper di EntryHandler nel codice sorgente:
Nota che il Mapper eredita da ViewHandler.ViewMapper: le proprietà comuni a tutte le view (BackgroundColor, Opacity, IsVisible...) sono gestite dal Mapper base, e ogni Handler specifico aggiunge solo ciò che gli è proprio. È un design elegante che ricorda la composizione classica dei componenti UIKit.
PlatformView e VirtualView: accedere ai controlli nativi
Dentro qualsiasi Handler, la proprietà PlatformView ti dà il riferimento diretto al controllo nativo. Il tipo di PlatformView dipende dalla piattaforma: su iOS è UITextField per un Entry, su Android è AppCompatEditText, su Mac Catalyst è ancora UITextField perché condivide il runtime UIKit. La proprietà VirtualView punta invece all'oggetto IEntry cross-platform.
Ecco un esempio concreto che ho scritto letteralmente decine di volte: vogliamo rimuovere il bordo di default degli Entry su iOS (l'iconico rettangolo grigio) e trasformarli in campi «flat» come Material Design. Nel MauiProgram.cs:
using Microsoft.Maui.Handlers;
#if IOS
using UIKit;
#endif
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
#if IOS
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoBorderiOS", (handler, view) =>
{
// handler.PlatformView è UITextField su iOS
handler.PlatformView.BorderStyle = UITextBorderStyle.None;
handler.PlatformView.BackgroundColor = UIColor.Clear;
});
#endif
builder.UseMauiApp<App>();
return builder.Build();
}
Il compilatore #if IOS è fondamentale perché UITextField esiste solo nel target iOS. Su Android faresti la stessa cosa con handler.PlatformView.SetBackground(null) dentro un blocco #if ANDROID. Se preferisci mantenere il codice organizzato per piattaforma, sposta questi mapping dentro file Platforms/iOS/EntryHandlerCustomizations.cs e chiamali dal MauiProgram.cs. Onestamente, in progetti oltre i 50 controlli custom, questa organizzazione fa la differenza tra un file MauiProgram.cs leggibile e uno che ti fa venire l'ansia solo ad aprirlo.
Modifica il Mapper prima che le azioni di default di .NET MAUI vengano applicate. Utile quando devi impostare uno stato nativo che poi verrà sovrascritto dalle proprietà cross-platform. Raramente necessario nella pratica (l'ho usato forse due volte in tre anni).
ModifyMapping
Sostituisce completamente il mapping esistente per una proprietà. Ricevi come parametro l'azione originale e puoi decidere di chiamarla o meno. È il modo giusto per bloccare un comportamento di default:
ButtonHandler.Mapper.ModifyMapping(nameof(Button.TextColor), (handler, view, originalAction) =>
{
// Ignoriamo il TextColor cross-platform e usiamo sempre il colore del brand
#if IOS
handler.PlatformView.SetTitleColor(UIColor.SystemBlue, UIControlState.Normal);
#elif ANDROID
handler.PlatformView.SetTextColor(Android.Graphics.Color.ParseColor("#1E88E5"));
#endif
});
AppendToMapping
È l'opzione che userai il 90% delle volte. Aggiunge la tua azione dopo che .NET MAUI ha applicato le proprie. Il nome della chiave («NoBorderiOS» nell'esempio precedente) è arbitrario: serve solo se poi vuoi rimuovere quel mapping con Mapper.RemoveMapping().
Come creare un Handler personalizzato da zero
Quando il controllo nativo che ti serve non ha un equivalente cross-platform (per esempio MKMapView di Apple o il PhotoView di Chris Banes su Android), è il momento di scrivere un Handler completamente nuovo. La documentazione Microsoft sui controlli custom descrive il pattern completo, ma il flusso essenziale è questo.
Prima definisci l'interfaccia della VirtualView e il controllo cross-platform:
public interface IVideoPlayer : IView
{
string SourceUri { get; }
bool IsPlaying { get; }
void Play();
void Pause();
}
public class VideoPlayer : View, IVideoPlayer
{
public static readonly BindableProperty SourceUriProperty =
BindableProperty.Create(nameof(SourceUri), typeof(string), typeof(VideoPlayer));
public string SourceUri
{
get => (string)GetValue(SourceUriProperty);
set => SetValue(SourceUriProperty, value);
}
public bool IsPlaying { get; private set; }
public void Play() => Handler?.Invoke(nameof(IVideoPlayer.Play));
public void Pause() => Handler?.Invoke(nameof(IVideoPlayer.Pause));
}
Poi crei una classe partial per l'Handler, con la definizione dei Mapper condivisi:
public partial class VideoPlayerHandler
{
public static IPropertyMapper<IVideoPlayer, VideoPlayerHandler> PropertyMapper =
new PropertyMapper<IVideoPlayer, VideoPlayerHandler>(ViewHandler.ViewMapper)
{
[nameof(IVideoPlayer.SourceUri)] = MapSourceUri,
};
public static CommandMapper<IVideoPlayer, VideoPlayerHandler> CommandMapper =
new(ViewHandler.ViewCommandMapper)
{
[nameof(IVideoPlayer.Play)] = MapPlay,
[nameof(IVideoPlayer.Pause)] = MapPause,
};
public VideoPlayerHandler() : base(PropertyMapper, CommandMapper) { }
}
Le due implementazioni platform-specific vivono in file separati: Platforms/iOS/VideoPlayerHandler.iOS.cs e Platforms/Android/VideoPlayerHandler.Android.cs. Ognuna eredita da ViewHandler<IVideoPlayer, AVPlayerViewController> (su iOS) o ViewHandler<IVideoPlayer, ExoPlayerView> (su Android), e implementa i metodi statici MapSourceUri, MapPlay, MapPause. Infine registri l'Handler in MauiProgram.cs:
A questo punto il tuo <VideoPlayer SourceUri="..." /> in XAML sarà completamente funzionante e integrato con Data Binding, MVVM Toolkit e Dependency Injection. Per approfondire i pattern MVVM che si sposano meglio con gli Handler custom, la nostra guida su MVVM e Dependency Injection è il complemento naturale a questa.
Ciclo di vita degli Handler e gestione degli eventi nativi
Uno degli errori più comuni con gli Handler è iscriversi a eventi nativi senza mai disiscriversi, causando memory leak invisibili. Il problema è particolarmente insidioso su iOS, dove i delegate mantengono riferimenti forti al target. Il framework offre due eventi specifici per gestire questo ciclo di vita in modo pulito: HandlerChanging e HandlerChanged.
HandlerChanging viene sollevato subito prima che un Handler venga sostituito. Nel suo HandlerChangingEventArgs trovi due proprietà: NewHandler (l'Handler in arrivo) e OldHandler (quello uscente). Quando OldHandler non è null, è il momento perfetto per rimuovere event handler, delegate e observer nativi:
public class VideoPlayer : View, IVideoPlayer
{
public VideoPlayer()
{
HandlerChanging += OnHandlerChanging;
}
private void OnHandlerChanging(object? sender, HandlerChangingEventArgs e)
{
if (e.OldHandler is VideoPlayerHandler oldHandler)
{
#if IOS
// Ferma la riproduzione e libera l'AVPlayer
oldHandler.PlatformView.Player?.Pause();
oldHandler.PlatformView.Player = null;
#endif
}
}
}
Se hai bisogno di sapere quando la nuova view nativa è pronta (per esempio per iniziare a caricare un video), sovrascrivi OnHandlerChanged nella classe VirtualView. Non fidarti mai del costruttore per accedere all'Handler: nel momento in cui il costruttore gira, l'Handler non è ancora stato istanziato. Questa è una trappola in cui ho visto cadere anche sviluppatori con dieci anni di esperienza su Xamarin.
Migrazione dai Custom Renderer di Xamarin.Forms
Se stai portando un'app Xamarin.Forms su .NET MAUI 10, hai due strade per i tuoi Custom Renderer esistenti. La prima è temporanea e si chiama compatibility bridge: aggiungi Microsoft.Maui.Controls.Compatibility e registri i vecchi renderer con AddCompatibilityRenderer. Funziona, ma paghi un overhead di runtime e perdi tutti i benefici dell'architettura Handler.
La seconda strada, quella che raccomando in ogni progetto che rimarrà in manutenzione più di sei mesi, è la riscrittura completa come Handler. Il pattern di conversione è quasi meccanico: le proprietà che gestivi in OnElementPropertyChanged diventano voci del PropertyMapper; le personalizzazioni in OnElementChanged quando e.NewElement era non-null vanno in ConnectHandler; quelle con e.OldElement non-null vanno in DisconnectHandler. Il tempo di conversione di un Renderer medio è di 30–60 minuti, ma il risultato è codice più leggero e testabile.
Un dettaglio spesso trascurato: in Xamarin.Forms i Renderer si registravano con ExportRenderer, un attributo che veniva scoperto tramite Assembly Scanning. In .NET MAUI la registrazione è esplicita tramite ConfigureMauiHandlers. È più codice da scrivere, ma il compilatore ora sa esattamente quali tipi servono, e il linker può fare tree-shaking aggressivo. Se hai già affrontato la migrazione della navigazione, la nostra guida su Shell Navigation, routing e deep linking copre il complemento naturale per la parte di UX.
Errori comuni e best practice di produzione
Ho visto le stesse trappole ripetersi in molti progetti che sono passati da Xamarin ad Handler MAUI. Ecco quelle che vale la pena conoscere prima di finirci dentro.
1. Modificare il Mapper dopo che l'app è avviata
Le modifiche al Mapper vanno fatte prima di builder.Build(). Se le applichi in un'altra parte del ciclo di vita, i controlli già renderizzati non vedranno le modifiche fino a quando non verranno ricreati. In uno dei miei primi porting ho perso un pomeriggio intero prima di capire perché lo styling non si applicava a runtime.
2. Confondere ConnectHandler e OnHandlerChanged
ConnectHandler(TPlatformView platformView) è un metodo protetto dell'Handler, chiamato una volta creata la view nativa. OnHandlerChanged è un metodo della VirtualView. Il primo è dove iscrivi eventi nativi; il secondo è dove notifichi il resto della tua app che l'Handler è pronto.
3. Ignorare i target Windows e Mac Catalyst
Anche se il tuo focus è iOS/Android, tenere Windows e Mac Catalyst nei tuoi target rende esplicite le assunzioni piattaforma-specifiche. Aggiungere #if WINDOWS nei tuoi Handler dall'inizio evita di ritrovarsi con un porting doloroso più avanti.
4. Bloccare i test
Gli Handler sono difficili da testare in isolamento perché dipendono dal runtime nativo. Il pattern che funziona è isolare la logica di business in una classe iniettata nel controllo cross-platform, testarla con xUnit, e trattare l'Handler come un semplice adapter di rendering. Se hai già impostato una pipeline di test, il resto viene naturale.
5. Dimenticare le release notes di .NET 10
Le release notes ufficiali del repository dotnet/maui documentano ogni cambio di API negli Handler minor per minor. Prima di aggiornare in produzione, controllare quella pagina risparmia ore di debugging.
Domande frequenti
Qual è la differenza principale tra Handler e Custom Renderer?
Un Custom Renderer di Xamarin.Forms ereditava dal renderer nativo e sottoclassava il comportamento. Un Handler di .NET MAUI è un oggetto separato, leggero, che collega VirtualView e PlatformView tramite un Mapper. Il Mapper è un dizionario che si estende senza sottoclassare, il che rende le personalizzazioni più semplici da mantenere e da testare.
Come si registra un Handler personalizzato in .NET MAUI 10?
Nel metodo CreateMauiApp del MauiProgram.cs si usa builder.ConfigureMauiHandlers(h => h.AddHandler<MyControl, MyControlHandler>()). A differenza di Xamarin.Forms, la registrazione è esplicita: niente Assembly Scanning, niente attributi di riflessione.
Posso personalizzare un Handler solo per alcuni controlli invece che globalmente?
Sì. Il pattern consigliato è aggiungere una BindableProperty personalizzata (per esempio EnableCustomBorder) al controllo cross-platform, e leggere quel valore dentro l'azione del Mapper. In alternativa, crea un controllo derivato (ad esempio FlatEntry : Entry) e registra un Handler dedicato solo per il tipo derivato.
Gli Handler funzionano anche su Windows e Mac Catalyst?
Sì, l'architettura Handler è cross-platform per design. Ogni Handler ha implementazioni platform-partial per iOS, Android, Windows (WinUI 3) e Mac Catalyst. Devi solo aggiungere il target framework corrispondente al tuo .csproj e implementare i metodi statici del Mapper per quella piattaforma.
Come si evitano memory leak quando si personalizza un Handler?
Iscriversi agli eventi nativi in ConnectHandler e disiscriversi in DisconnectHandler. Su iOS, evitare di assegnare la VirtualView come delegate diretto di controlli UIKit; usare invece una classe delegate separata che manteniamo con un riferimento debole. Il pattern HandlerChanging con controllo di OldHandler è il modo ufficiale per cleanup deterministico.
Playbook DevOps completo per firmare e distribuire un'app .NET MAUI 10 nel 2026: keystore Android, provisioning iOS, workflow GitHub Actions pronto all'uso e gotcha su Play Store e App Store.
Guida pratica al testing di app .NET MAUI nel 2026: unit test dei ViewModel con xUnit e NSubstitute, device test per handler custom, UI test end-to-end con Appium 2.11 e integrazione CI/CD con GitHub Actions, con esempi di codice reali.