Actualizado: 18 de septiembre de 2026
Los handlers personalizados en .NET MAUI 10 son las clases puente que conectan un control abstracto (por ejemplo, Button) con su vista nativa real (UIButton en iOS, AppCompatButton en Android) y permiten modificar cualquier propiedad de esa vista nativa sin subclasear. Se registran una sola vez en MauiProgram.cs y trabajan sobre dos diccionarios estáticos (PropertyMapper y CommandMapper) que reciben la señal cuando cambia una propiedad enlazada. Todo lo que aquí explico está verificado sobre .NET 10 GA (noviembre 2025) y Xcode 17, y sobre un par de apps que llevé a producción este verano.
¿Qué son los handlers en .NET MAUI 10?
Un handler es la clase intermedia que .NET MAUI usa para materializar un control cross-platform en su equivalente nativo. Cuando declaras <Button Text="Hola" /> en XAML, el runtime instancia un Microsoft.Maui.Controls.Button (la virtual view) y le asigna un ButtonHandler que, por debajo, crea un UIButton en iOS o un MaterialButton en Android (la platform view). El handler no hereda del control, ni del nativo: solo actúa como pegamento.
La lista completa de handlers integrados vive en el ensamblado Microsoft.Maui y sigue el patrón I{Control}Handler: IButtonHandler, IEntryHandler, ILabelHandler, etc. La documentación oficial de Microsoft sobre handlers mantiene una tabla actualizada por versión. En .NET 10 el equipo eliminó tres handlers heredados de Xamarin (WebViewLegacyHandler, entre otros) y añadió IHybridWebViewHandler para el nuevo control híbrido de Blazor.
Lo importante: cada handler expone dos diccionarios estáticos que rigen su comportamiento. Mapper (o PropertyMapper) reacciona a cambios de propiedades enlazadas, y CommandMapper reacciona a llamadas explícitas al método Invoke. Modificar esos diccionarios es lo que te permite personalizar un control globalmente sin escribir una sola subclase.
Handler vs Renderer: ¿cuál es la diferencia?
Los Custom Renderers de Xamarin.Forms exigían herencia: creabas una clase MyButtonRenderer : ButtonRenderer, sobrescribías OnElementChanged, y luego decorabas con [assembly: ExportRenderer]. Cualquier cambio implicaba reescribir la cadena de herencia entera, y compartir código entre plataformas era doloroso porque cada renderer vivía en su carpeta específica.
Los handlers atacan el mismo problema desde otro ángulo. No hay herencia, no hay atributos globales, no hay OnElementChanged. Solo dos operaciones: registrar un mapping o registrar una acción sobre un comando. Esta tabla resume las diferencias que más impactan al día a día:
| Aspecto | Custom Renderer (Xamarin.Forms) | Handler (.NET MAUI 10) |
|---|---|---|
| Modelo de extensión | Herencia obligatoria | Composición sobre Mapper |
| Registro | [assembly: ExportRenderer] | ConfigureMauiHandlers en MauiProgram |
| Acceso al nativo | Control (tipado según renderer) | PlatformView (tipo nativo real) |
| Ciclo de vida | OnElementChanged | ConnectHandler / DisconnectHandler |
| Modificación global sin subclase | No soportada | Sí, editando el mapper estático |
| Rendimiento de arranque | Alto coste por reflexión | Resolución directa vía DI |
La consecuencia práctica: en MAUI puedes modificar el aspecto de todos los Entry de tu app (quitar el subrayado en Android, cambiar el color del cursor en iOS) con seis líneas dentro de MauiProgram.cs, sin crear ni una subclase. Eso era literalmente imposible en Xamarin.Forms.
Cómo modificar un handler existente sin crear una subclase
Honestamente, este es el patrón que uso el 80 % del tiempo. Imagina que quieres eliminar el subrayado que Android pinta debajo de todo Entry (viene del tema Material) y ocultar el borde en iOS. En lugar de subclasear EntryHandler, basta con inyectar dos entradas en el mapper:
// En MauiProgram.cs, antes de builder.Build()
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("SinBordes", (handler, view) =>
{
#if ANDROID
handler.PlatformView.BackgroundTintList =
Android.Content.Res.ColorStateList.ValueOf(
Android.Graphics.Color.Transparent);
#elif IOS
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});AppendToMapping registra una acción que se ejecutará después de todos los mappings por defecto, así que tus cambios sobrescriben lo que MAUI habría hecho normalmente. Si necesitas ejecutar antes, existe PrependToMapping; si necesitas reemplazar por completo la lógica de una propiedad concreta, usa ModifyMapping.
Un detalle que la documentación menciona en letra pequeña: el mapping se dispara una vez por cada instancia del control cuando el handler se conecta. No se ejecuta cada vez que cambia el valor de la propiedad; para eso hay que suscribirse dentro de ConnectHandler. Si vienes de MVVM con CommunityToolkit.Mvvm, piensa en el mapping como un partial ejecutado en la construcción del control nativo, no como un observador.
Crear un handler personalizado paso a paso
Cuando lo que necesitas es un control completamente nuevo (por ejemplo, un VideoPlayer que envuelva AVPlayer en iOS y ExoPlayer en Android), sí que hay que crear tu propio handler. Los pasos son cinco y siempre son los mismos:
- Definir la vista abstracta. Una clase que herede de
View(oElement) con las propiedades bindables que quieres exponer. - Crear la clase parcial del handler con
PropertyMapperyCommandMapper. - Implementar
CreatePlatformViewen cada plataforma (archivo con sufijo.iOS.cs,.Android.cs, etc.). - Implementar
ConnectHandleryDisconnectHandlerpara gestionar suscripciones y liberar recursos. - Registrar el handler en
MauiProgram.cs.
Un ejemplo mínimo de un control PulsingButton que hace pulsar un botón cada segundo:
// PulsingButton.cs - vista abstracta compartida
public class PulsingButton : View
{
public static readonly BindableProperty TextProperty =
BindableProperty.Create(nameof(Text), typeof(string), typeof(PulsingButton), string.Empty);
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
}
// PulsingButtonHandler.cs - mapper compartido
public partial class PulsingButtonHandler
{
public static IPropertyMapper<PulsingButton, PulsingButtonHandler> PropertyMapper =
new PropertyMapper<PulsingButton, PulsingButtonHandler>(ViewMapper)
{
[nameof(PulsingButton.Text)] = MapText,
};
public PulsingButtonHandler() : base(PropertyMapper) { }
}Después, un archivo parcial por plataforma se encarga de crear la vista nativa. La guía oficial para crear handlers tiene el ejemplo completo iOS + Android + Windows. En un proyecto real yo suelo empezar copiando ese esqueleto y borrar Windows si no lo necesito: reduce el ruido del proyecto y compila un 20 % más rápido.
PlatformView y VirtualView: acceso a la vista nativa
Dentro de un handler tienes dos propiedades siempre disponibles: VirtualView es la vista MAUI (por ejemplo, Button) y PlatformView es la vista nativa real. El tipo de PlatformView se resuelve por plataforma en compile-time:
- En iOS es
UIKit.UIButton(o elUIViewconcreto para tu control). - En Android es
Google.Android.Material.Button.MaterialButton. - En Mac Catalyst se comparte con iOS.
- En Windows (WinUI 3) es
Microsoft.UI.Xaml.Controls.Button.
Aquí es donde cualquier desarrollador que venga de Swift o Kotlin empieza a agradecer al equipo de MAUI. Como PlatformView es el tipo nativo real, tienes acceso a toda la API de Apple o Google. Si abres la referencia de UIButton en developer.apple.com, cualquier método de esa página es alcanzable dentro de #if IOS. Nada de wrappers artificiales, nada de propiedades a medias.
La contrapartida es que tus archivos platform-specific tienen que compilar contra los SDKs nativos. Por eso .NET MAUI genera un target framework por plataforma (net10.0-ios, net10.0-android) y por eso el hot reload a veces se rompe cuando editas ficheros .iOS.cs: el compilador tiene que regenerar los bindings.
Ciclo de vida: ConnectHandler, DisconnectHandler y fugas de memoria
ConnectHandler(platformView) se ejecuta una vez, cuando la vista nativa acaba de crearse y está a punto de añadirse al árbol visual. Es el lugar donde enganchas eventos nativos, gestores de gestos, delegates de UITextField, listeners de Android, etc. DisconnectHandler(platformView) se ejecuta cuando el control se desconecta del árbol, y es donde tienes que desenganchar todo.
Este es el error número uno que veo en revisiones de código: los desarrolladores suscriben eventos en ConnectHandler pero olvidan desuscribirlos en DisconnectHandler. En Android el garbage collector es lo suficientemente agresivo para disimularlo, pero en iOS el UIViewController queda retenido en memoria hasta que Xcode Instruments te avisa. En una app con navegación intensiva eso equivale a decenas de MB perdidos por hora. Me pilló en el primer proyecto MAUI que llevé a la App Store, y desde entonces reviso este par en cada pull request.
protected override void ConnectHandler(UIButton platformView)
{
base.ConnectHandler(platformView);
platformView.TouchUpInside += OnClicked;
}
protected override void DisconnectHandler(UIButton platformView)
{
platformView.TouchUpInside -= OnClicked; // imprescindible
base.DisconnectHandler(platformView);
}
private void OnClicked(object? sender, EventArgs e) => VirtualView?.SendClicked();Registrar handlers en MauiProgram.cs
Todo handler personalizado (o toda modificación del mapper de un handler existente) pasa por MauiProgram.cs. La API es ConfigureMauiHandlers y admite tanto registro puntual como registros por convención:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureMauiHandlers(handlers =>
{
// Handler completamente nuevo
handlers.AddHandler<PulsingButton, PulsingButtonHandler>();
// Reemplazar handler integrado
handlers.AddHandler(typeof(Entry), typeof(MiEntryHandlerPersonalizado));
});
// Modificar el mapper del handler integrado sin subclasear
Microsoft.Maui.Handlers.LabelHandler.Mapper.AppendToMapping("SombraTexto", (h, v) =>
{
#if IOS
h.PlatformView.Layer.ShadowOpacity = 0.4f;
h.PlatformView.Layer.ShadowRadius = 2f;
#elif ANDROID
h.PlatformView.SetShadowLayer(2f, 1f, 1f, Android.Graphics.Color.Gray);
#endif
});
return builder.Build();
}La sección migrar de Xamarin.Forms a .NET MAUI incluye un mapa completo de conversión: dónde antes tenías [assembly: ExportRenderer], ahora usarás handlers.AddHandler; dónde tenías OnElementChanged, ahora usarás ConnectHandler. La regla es directa: todo lo que estaba disperso en atributos globales queda centralizado en MauiProgram.cs.
Errores comunes al usar handlers personalizados
Sinceramente, tras meses depurando handlers en varias apps, estos son los cinco fallos que aparecen una y otra vez, en el orden en que muerden:
- Olvidar
DisconnectHandler. Ya lo mencioné, pero merece repetirse: si suscribes eventos, desuscribe. Xcode Instruments > Leaks lo detecta en un minuto. - Suponer que
VirtualViewnunca es null. Durante la desconexión puede serlo. Usa siempre el operador?.:VirtualView?.SendClicked(). - Modificar el mapper varias veces por hot-reload. Si haces
AppendToMappingen tiempo de ejecución (no enMauiProgram), cada hot-reload añade una entrada. La aplicación acaba ejecutando la misma acción diez veces por control. - Confundir
HandlerconHandler.PlatformView. Cuando accedes fuera del handler, conmyButton.Handler.PlatformView, tienes que castear al tipo nativo esperado en cada plataforma. Un cast fallido tira unaInvalidCastExceptionque solo se ve en Release. - Registrar el handler después de
Build(). Los handlers registrados fuera deConfigureMauiHandlersno se aplican a controles ya instanciados. La regla es: todo antes debuilder.Build().
Para depurar el ciclo de vida, activa el logging interno de MAUI con builder.Logging.AddDebug() y filtra por Microsoft.Maui.Handlers. Verás cada ConnectHandler, DisconnectHandler y cada llamada al mapper. Es más barato que abrir Instruments para un caso puntual.
Hay un sexto error que solo aparece en apps grandes: reordenar el diccionario del mapper. Cuando dos librerías (por ejemplo, un tema visual de terceros y tu propio código) llaman AppendToMapping sobre la misma clave, gana la última en registrarse. Si dependes de ese orden, documéntalo o convierte tu registro en un ModifyMapping explícito para dejar clara la intención. En una app que auditamos el año pasado, esa ambigüedad provocaba que el color del cursor cambiara al reordenar los paquetes NuGet, un bug imposible de rastrear sin este contexto.
Por último, si estás usando SQLite para almacenamiento local y guardas configuraciones visuales que aplicas mediante handlers, cachea el resultado en memoria. Cada AppendToMapping es rápido, pero ejecutarlo cientos de veces al pintar una lista virtualizada acaba notándose en dispositivos gama media.
Preguntas frecuentes
¿Puedo tener un handler distinto en iOS y otro en Android para el mismo control?
No es necesario. Un único handler partial con archivos .iOS.cs y .Android.cs resuelve cada método CreatePlatformView en la plataforma correcta. El registro es único en ConfigureMauiHandlers.
¿Los handlers funcionan en .NET MAUI Blazor Hybrid?
Sí, los controles nativos que aparecen fuera del BlazorWebView usan handlers estándar. Dentro del BlazorWebView, en cambio, los componentes se renderizan como HTML/CSS y no pasan por el sistema de handlers.
¿Es más rápido un handler que un renderer de Xamarin.Forms?
Sí. El equipo de MAUI publicó benchmarks en el repositorio de dotnet/maui mostrando entre un 20 % y un 40 % menos de tiempo en la resolución de controles, dependiendo del volumen de vistas por página. La diferencia proviene de haber eliminado la reflexión en tiempo de arranque.
¿Puedo modificar el handler de un control solo en una página, no globalmente?
Sí, aunque no es lo habitual. Puedes acceder al handler de una instancia concreta con miBoton.Handler tras el evento Loaded y modificar la PlatformView directamente. Recuerda revertir el cambio en el evento Unloaded si la vista se reutiliza.
¿Qué diferencia hay entre PropertyMapper y CommandMapper?
PropertyMapper reacciona a cambios de propiedades enlazadas (Text, Color, IsEnabled...). CommandMapper reacciona a llamadas explícitas invocadas por el propio handler cuando la vista virtual emite una acción, típicamente para métodos como Focus() o Unfocus().




