.NET MAUI Handler architektúra: natív vezérlők testreszabása PropertyMapper-rel (2026)

.NET MAUI Handler architektúra útmutató: PropertyMapper, CommandMapper és natív vezérlők testreszabása iOS-en és Android-on, éles kódpéldákkal.

.NET MAUI Handler: PropertyMapper Guide 2026

Frissítve: 2026. augusztus 22.

A .NET MAUI Handler egy vékony, kétirányú leképező réteg, amely a cross-platform vezérlő tulajdonságait a natív iOS UIView-hoz vagy Android View-hoz köti egy PropertyMapper szótáron keresztül, anélkül hogy a Xamarin.Forms renderer-eknél megszokott extra wrapper hierarchiát létrehozná. Ez az útmutató végigmegy a Handler architektúra minden rétegén (a PlatformView/VirtualView viszonytól kezdve az AppendToMapping mintákon át a saját, több platformra célzó custom control írásáig), és megmutatja, mi történik valójában a felszín alatt, amikor egy Button Android-on MaterialButton-ná, iOS-en pedig UIButton-ná válik.

  • A Handler minden .NET MAUI vezérlőnek van (interfészen keresztül, pl. IButton, IEntry), és a PlatformView property adja vissza a valódi natív kontrollt.
  • A PropertyMapper egy Dictionary<string, Action>: minden cross-platform property-hez tartozik egy delegate, ami frissíti a natív viewt. Ez váltja fel az OnElementPropertyChanged switch-case-eket.
  • A CommandMapper ugyanez, csak imperatív parancsokra (pl. Focus, Unfocus), amiket a MAUI a natív viewnak küld.
  • Beépített vezérlőt globálisan az AppendToMapping, PrependToMapping vagy ModifyMapping hívásokkal szabhatsz testre. Figyelem: minden ilyen példányra hat.
  • Saját custom control esetén partial osztályokkal a Platforms/iOS és Platforms/Android mappákba tesszük a natív implementációt, és MauiProgram.CreateMauiApp-ban ConfigureMauiHandlers-szel regisztráljuk.
  • A Handler-ek globálisak: nincs per-page izoláció, ezért a mapper módosítás mindig „app-wide" hatású, hacsak feltételes logikát nem építesz be.

Mi az a Handler architektúra a .NET MAUI-ban?

Röviden: a Handler egy .NET MAUI-ban minden UI vezérlőhöz tartozó, platformonként partial-ként létező osztály, amelynek egyetlen dolga van. A cross-platform „virtual view" (pl. a Microsoft.Maui.Controls.Button mögötti IButton interfész) tulajdonságait és parancsait átfordítani a natív platformon élő valódi vezérlőre. iOS-en ez egy UIButton, Android-on egy Google.Android.Material.Button.MaterialButton, Windows-on pedig egy Microsoft.UI.Xaml.Controls.Button.

Fontos, hogy a MAUI szándékosan a Material Design komponensre bind-ol Android-on (nem a plain android.widget.Button-ra), mert ezek a Google által ajánlott, ripple-t és elevációt támogató kontrollok. Ha valaha megnézed az Android UI Inspectorral, tényleg a Material réteg jelenik meg, nem az AppCompat. Én is meglepődtem ezen az első MAUI projektnél, mert Xamarin.Forms-ban még az AppCompat volt az alap.

A Handler minden osztályhoz elérhetővé tesz két kulcsfontosságú property-t: PlatformView és VirtualView. A PlatformView a natív típusra van kasztolva (iOS-en UIButton, Android-on MaterialButton), és bármely natív API-t közvetlenül hívhatod rajta, például iOS-en handler.PlatformView.SetImage(image, UIControlState.Normal). A VirtualView pedig a MAUI absztrakt vezérlőt adja vissza (a IButton interfészt), amin keresztül a cross-platform property értékeket olvasod.

Ez az egyszerű kétirányú kapcsolat az egész architektúra alapja. Nincs örökléses wrapper, nincs harmadik réteg. Ha a MAUI hivatalos Handlers overview dokumentációját megnyitod, ugyanezt az „inverz" viszonyt fogod látni: a platform már nem a keretrendszert szolgálja ki, hanem fordítva.

Handler vs Renderer: mi változott a Xamarin.Forms óta?

Ha Xamarin.Forms-ból jönnél (mint a legtöbb MAUI fejlesztő ebben az évben), a legszembetűnőbb különbség, hogy nincs többé ElementChanged vagy OnElementPropertyChanged. A Renderer-modell egy örökléses hierarchián alapult: minden Renderer a natív view-ból származott (pl. iOS-en ButtonRenderer : UIButton), ami azt jelentette, hogy a MAUI keretrendszer minden vezérlőhöz létrehozott egy wrappelt subclass-t, és minden property változásra egy nagy switch-ben reagáltunk. Ez lassú volt, felfújta az UI treet, és a memóriában a natív hierarchián két „duplikált" node jelent meg.

A Handler modell ezt teljesen kidobja. A natív view már nem örököl semmiből, csak példányosítjuk (CreatePlatformView()), majd a PropertyMapper szótáron át kötjük össze a MAUI property-kkel. Az alábbi táblázat összefoglalja a legfontosabb különbségeket, amiket a Xamarin.Forms-ról MAUI-ra migrálókkal a legtöbbet átbeszéltem.

SzempontXamarin.Forms Renderer.NET MAUI Handler
Alap mintaÖröklés (Renderer : NativeView)Kompozíció (Handler → PlatformView property)
Property változásOnElementPropertyChanged switchPropertyMapper Dictionary<string, Action>
Regisztráció[assembly: ExportRenderer]ConfigureMauiHandlers().AddHandler<T,H>
Cross-platform interfészNincs (közvetlenül a View)IView-származék (pl. IButton, IEntry)
UI overheadWrapper subclass minden vezérlőreNincs wrapper, natív típus közvetlenül
TestreszabásÚj Renderer + assembly attribútumAppendToMapping / PrependToMapping
TesztelhetőségNehéz (natív dep. minden szinten)Könnyű (mapper delegate mockolható)

A gyakorlatban ez pár tíz százalékos memória-csökkenést és mérhető layout gyorsulást jelent, és nem véletlen, hogy a MAUI teljesítménye érezhetően jobb komplex listáknál. Erről bővebben írtam a .NET MAUI teljesítményoptimalizálás cikkben, ahol pontosabb számokat is találsz a Native AOT-vel párosított handler layerről.

A PropertyMapper és a CommandMapper működése

A PropertyMapper típusa a MAUI forráskódban lényegében ez: Dictionary<string, Action<IElementHandler, IElement>>. A kulcs a cross-platform property neve (jellemzően nameof(IButton.Text) formában), a value pedig egy delegate, amit a keretrendszer akkor hív, amikor a MAUI vezérlőn megváltozik a bekötött érték. Ez a design szándékosan minimalista: nincs reflection, nincs runtime string parsing, csak egy dictionary lookup és egy delegate hívás.

Nézzük egy konkrét MAUI példát, ahogy egy egyszerű Handler-nél kinéz a mapper (a MAUI forrás mintájára):

public partial class CustomEntryHandler : ViewHandler<ICustomEntry, UIView>
{
    public static IPropertyMapper<ICustomEntry, CustomEntryHandler> Mapper =
        new PropertyMapper<ICustomEntry, CustomEntryHandler>(ViewHandler.ViewMapper)
        {
            [nameof(ICustomEntry.Text)]        = MapText,
            [nameof(ICustomEntry.PlaceholderColor)] = MapPlaceholderColor,
            [nameof(ICustomEntry.IsPassword)]  = MapIsPassword,
        };

    public static ICommandMapper<ICustomEntry, CustomEntryHandler> CommandMapper =
        new CommandMapper<ICustomEntry, CustomEntryHandler>(ViewHandler.ViewCommandMapper)
        {
            [nameof(ICustomEntry.Focus)]   = MapFocus,
            [nameof(ICustomEntry.Unfocus)] = MapUnfocus,
        };

    public CustomEntryHandler() : base(Mapper, CommandMapper) { }
}

A MapText egy statikus metódus, ami a handlert és a virtuális viewt kapja: static void MapText(CustomEntryHandler h, ICustomEntry v) => h.PlatformView.SetText(v.Text). A statikus signature nem véletlen: a MAUI csapata szándékosan úgy tervezte, hogy a mapping függvények mentesek legyenek példány-állapottól, mert így könnyebb őket felülírni, cserélni és tesztelni.

A CommandMapper ehhez képest imperatív parancsokat közvetít. Amikor a UI thread myEntry.Focus()-t hív, a MAUI ezt nem property változásként kezeli, hanem a CommandMapper-be belookup-olja a Focus kulcsot, és meghívja a hozzá tartozó delegatet. Ez a szétválasztás azért lényeges, mert a Focus/Unfocus/gesztus jellegű dolgok nem property értékek, hanem egyszeri „utasítások", és a Dictionary így természetesen szeparálja őket a state-től.

Hogyan szabjuk testre a beépített vezérlők natív viselkedését?

A leggyakoribb Handler-igény nem az, hogy nulláról vezérlőt írj, hanem hogy egy MAUI Entry vagy Button valamilyen platform-specifikus viselkedést kapjon. Például az iOS UITextField ReturnKeyType-ja legyen UIReturnKeyType.Done, vagy az Android Entry alján tűnjön el az az idegesítő aláhúzás. Erre való a három mapper-módosító metódus: AppendToMapping, PrependToMapping és ModifyMapping. Mind a három a MAUI beépített mapperjét bővíti, a különbség csak az, hogy a te delegate-ed a meglévő MAUI mapping előtt vagy után fut, illetve teljesen felváltja azt.

Az iOS Entry aláhúzás-eltávolítása például így néz ki (globálisan, minden Entry-re):

#if IOS
using Microsoft.Maui.Handlers;
using UIKit;

Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(
    "NoBorder",
    (handler, view) =>
    {
        handler.PlatformView.BorderStyle = UITextBorderStyle.None;
        handler.PlatformView.BackgroundColor = UIColor.Clear;
    });
#endif

#if ANDROID
using Microsoft.Maui.Handlers;
using Android.Graphics.Drawables;

Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(
    "NoUnderline",
    (handler, view) =>
    {
        handler.PlatformView.Background = new ColorDrawable(
            Android.Graphics.Color.Transparent);
    });
#endif

Ezt tipikusan a MauiProgram.CreateMauiApp metódusban futtatjuk, a builder.Build() előtt. Ez fontos: a mapper globális állapot, ezért ha később hozzányúlsz, minden korábban létrehozott Handler példány is felveszi az új viselkedést a következő layout ciklusnál, nem csak az utána példányosítottak.

Mikor válassz AppendToMapping és PrependToMapping között? Egyszerű ökölszabály: ha a MAUI alap viselkedésére akarsz építeni (pl. a MAUI beállítja a szöveg színét, te utána még finomítasz), akkor AppendToMapping. Ha viszont az alap MAUI mapping véletlenül felülírja a te értékedet (mert később fut), akkor PrependToMapping-gal fusson a tiéd, majd hagyd, hogy a MAUI utána tegye a dolgát. A ModifyMapping-ot ritkán használjuk, mert az teljesen leváltja a MAUI beépített delegate-jét, és 90%-ban az alap logika elvesztésével jár.

Saját cross-platform vezérlő létrehozása lépésről lépésre

Nézzük meg, hogyan írjunk egy egyszerű custom controlt, mondjuk egy „ShimmerLabel"-t, ami egy shimmer (fényváltó) animációt mutat, amíg tartalmat töltünk. Ezt a MAUI-ban 4 lépésben csináljuk meg. A hivatalos Microsoft Create custom controls with .NET MAUI handlers útmutató is ezt a mintát követi, csak itt egy realisztikusabb példával megyünk végig.

1. Interfész és cross-platform osztály

// Controls/IShimmerLabel.cs
public interface IShimmerLabel : IView
{
    string Text { get; set; }
    bool IsShimmering { get; set; }
    Color HighlightColor { get; set; }
}

// Controls/ShimmerLabel.cs
public class ShimmerLabel : View, IShimmerLabel
{
    public static readonly BindableProperty TextProperty =
        BindableProperty.Create(nameof(Text), typeof(string),
            typeof(ShimmerLabel), string.Empty);

    public static readonly BindableProperty IsShimmeringProperty =
        BindableProperty.Create(nameof(IsShimmering), typeof(bool),
            typeof(ShimmerLabel), false);

    public static readonly BindableProperty HighlightColorProperty =
        BindableProperty.Create(nameof(HighlightColor), typeof(Color),
            typeof(ShimmerLabel), Colors.LightGray);

    public string Text { get => (string)GetValue(TextProperty); set => SetValue(TextProperty, value); }
    public bool IsShimmering { get => (bool)GetValue(IsShimmeringProperty); set => SetValue(IsShimmeringProperty, value); }
    public Color HighlightColor { get => (Color)GetValue(HighlightColorProperty); set => SetValue(HighlightColorProperty, value); }
}

2. Partial Handler osztály (shared)

// Handlers/ShimmerLabelHandler.cs (shared, minden platformon fordul)
using Microsoft.Maui.Handlers;

public partial class ShimmerLabelHandler
{
    public static IPropertyMapper<IShimmerLabel, ShimmerLabelHandler> PropertyMapper =
        new PropertyMapper<IShimmerLabel, ShimmerLabelHandler>(ViewMapper)
        {
            [nameof(IShimmerLabel.Text)]           = MapText,
            [nameof(IShimmerLabel.IsShimmering)]   = MapIsShimmering,
            [nameof(IShimmerLabel.HighlightColor)] = MapHighlightColor,
        };

    public ShimmerLabelHandler() : base(PropertyMapper) { }
}

3. Platform-specifikus partial (iOS)

// Platforms/iOS/Handlers/ShimmerLabelHandler.cs
using UIKit;
using Microsoft.Maui.Handlers;

public partial class ShimmerLabelHandler : ViewHandler<IShimmerLabel, UILabel>
{
    protected override UILabel CreatePlatformView() => new UILabel();

    static void MapText(ShimmerLabelHandler h, IShimmerLabel v) =>
        h.PlatformView.Text = v.Text;

    static void MapHighlightColor(ShimmerLabelHandler h, IShimmerLabel v) =>
        h.PlatformView.TextColor = v.HighlightColor.ToPlatform();

    static void MapIsShimmering(ShimmerLabelHandler h, IShimmerLabel v)
    {
        if (v.IsShimmering)
            h.PlatformView.Layer.AddAnimation(BuildShimmer(), "shimmer");
        else
            h.PlatformView.Layer.RemoveAnimation("shimmer");
    }

    static CoreAnimation.CABasicAnimation BuildShimmer()
    {
        var anim = CoreAnimation.CABasicAnimation.FromKeyPath("opacity");
        anim.From = Foundation.NSNumber.FromFloat(1f);
        anim.To   = Foundation.NSNumber.FromFloat(0.3f);
        anim.Duration = 0.8;
        anim.AutoReverses = true;
        anim.RepeatCount = float.PositiveInfinity;
        return anim;
    }
}

4. Platform-specifikus partial (Android) és regisztráció

// Platforms/Android/Handlers/ShimmerLabelHandler.cs
using Android.Animation;
using Android.Widget;
using Microsoft.Maui.Handlers;

public partial class ShimmerLabelHandler : ViewHandler<IShimmerLabel, TextView>
{
    ObjectAnimator? _animator;

    protected override TextView CreatePlatformView() =>
        new TextView(Context ?? throw new InvalidOperationException("No Context"));

    static void MapText(ShimmerLabelHandler h, IShimmerLabel v) =>
        h.PlatformView.Text = v.Text;

    static void MapHighlightColor(ShimmerLabelHandler h, IShimmerLabel v) =>
        h.PlatformView.SetTextColor(v.HighlightColor.ToPlatform());

    static void MapIsShimmering(ShimmerLabelHandler h, IShimmerLabel v)
    {
        h._animator?.Cancel();
        if (!v.IsShimmering) return;
        h._animator = ObjectAnimator.OfFloat(h.PlatformView, "alpha", 1f, 0.3f);
        h._animator.SetDuration(800);
        h._animator.RepeatMode  = ValueAnimatorRepeatMode.Reverse;
        h._animator.RepeatCount = ValueAnimator.Infinite;
        h._animator.Start();
    }
}

// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .ConfigureMauiHandlers(handlers =>
        {
            handlers.AddHandler<ShimmerLabel, ShimmerLabelHandler>();
        });
    return builder.Build();
}

Ez után XAML-ből ugyanolyan first-class polgárként tudod használni: <controls:ShimmerLabel Text="{Binding UserName}" IsShimmering="{Binding IsLoading}" />. Ha az MVVM oldalról jönnél, és a Handlerbe DI-jal szeretnél service-eket injektálni (pl. logger), az szintén megoldható. Erről a .NET MAUI Dependency Injection útmutatóban megtalálod a szolgáltatás-élettartamok részleteit.

Platform-specifikus kód: partial osztályok és feltételes fordítás

A partial osztályos megközelítés (amit fent láttunk) messze a legtisztább minta, mert a .csproj a Platforms/iOS, Platforms/Android, Platforms/Windows mappákat automatikusan a megfelelő target framework alá fordítja. Ez azt jelenti, hogy az iOS partial csak akkor kerül lefordításra, ha a net10.0-ios targetet buildeled, ezért lehet benne bátran using UIKit;. Nincs #if IOS, nincs guard, nincs macera.

Az alternatíva a feltételes fordítás egyetlen fájlon belül, amit főleg akkor használunk, ha app-oldalon (tehát nem library-ben) csak egy pár sor natív kódot kell injektálni:

public static void ApplyPlatformTweaks()
{
#if IOS
    UIKit.UIApplication.SharedApplication.KeyWindow!
        .WindowScene!.StatusBarManager!.SetStatusBarHidden(true, UIKit.UIStatusBarAnimation.Fade);
#elif ANDROID
    var activity = Platform.CurrentActivity;
    activity?.Window?.AddFlags(Android.Views.WindowManagerFlags.Fullscreen);
#endif
}

Egy köztes megoldás a részleges class + [Conditional("IOS")] attribútum, de a saját tapasztalatom szerint ez inkább zavart okoz, mint tisztaságot. Én az elmúlt évben egyszer belefutottam, hogy a build átment, de release módban az attribútum által kivágott hívás miatt a natív hozzáférés csendben elveszett. Maradj a partial + Platforms mappa mintánál, mert a MAUI csapata is ezt ajánlja a Porting Custom Renderers to Handlers wiki oldalon, és ez lesz a hosszú távon karbantartható struktúra.

Gyakori hibák és éles projektekben bevált gyakorlatok

Az elmúlt évben végigcsináltam pár Xamarin.Forms → MAUI migrációt, és összegyűjtöttem a leggyakoribb Handler-buktatókat. Ezek nem trükkösek, de mindegyikbe belefutottunk élesben (őszintén, volt köztük olyan, ami két napig tartott, mire megtaláltuk).

1. Elfelejtett DisconnectHandler

A MAUI 8 óta a Handler példány automatikusan nem hívja meg a DisconnectHandler-t view eldobásakor. Ez opt-in a ViewHandler.ViewMapper beállítással. Ha natív eseményekre iratkoztál fel (pl. iOS UITextField.EditingChanged), és nem iratkozol le a DisconnectHandler()-ben, memory leak-et csinálsz. A biztonságos minta:

protected override void ConnectHandler(UITextField platformView)
{
    base.ConnectHandler(platformView);
    platformView.EditingChanged += OnEditingChanged;
}

protected override void DisconnectHandler(UITextField platformView)
{
    platformView.EditingChanged -= OnEditingChanged;
    base.DisconnectHandler(platformView);
}

2. Mapper regisztráció túl későn

A AppendToMapping hívást a MauiProgram.CreateMauiApp-on belül végezd, még a Build() előtt. Ha később csinálod (pl. egy App.xaml.cs konstruktorban), akkor az addig példányosodott Handler-ek már nem futtatják a mapping-et frissítéskor. Ez sok fejlesztőt megzavart, mert Xamarin.Forms-ban a Renderer regisztráció bárhonnan is működött.

3. Handler property módosítás UI szálon kívül

A PlatformView-t iOS-en és Android-on is szigorúan a UI szálról érheted el. Ha egy háttérszálon fut a mapping delegate-ed (mert pl. egy async callback-ből triggerelted), csomagold be: MainThread.BeginInvokeOnMainThread(() => handler.PlatformView.SomeMethod()). Az iOS Metal renderelés csendes segfaultot dob, ha nem a main threaden vagy, Android-on legalább kap egy CalledFromWrongThreadException-t az ember. Én pont ebbe futottam bele egy tavalyi projektnél, és jó két órámba került, amíg a stack trace-ből kiderült, hogy a hiba egy távoli Task.Run-ból eredt.

4. A Handler globális jellegének félreértése

Sokan próbálnak „csak ezen az egy oldalon" jellegű mapper módosítást csinálni, ami a Handler modelben csapdába vezet. Ha a viselkedés page-specifikus, oldd meg a Behaviors API-val vagy egy dedikált MyEntry : Entry subclass-szal. Ez a szemlélet jobban illeszkedik a MAUI kompozíciós filozófiájához, és az MVVM oldalt tisztán tartja. A CommunityToolkit.Mvvm útmutatóban részleteztem, hogy egy jól strukturált ViewModel milyen könnyen jön össze forrásgenerátorokkal.

5. Windows platform hiánya

Ha kizárólag iOS + Android-ra fejlesztesz, könnyű elfelejteni a Windows/MacCatalyst partial osztályokat. A build ilyenkor átmegy (mert a Windows target nem szerepel a .csproj-ban), de amint valaki windows-ra próbál buildelni, forráshiány miatt hasal el. Vagy add hozzá az összes platformot, vagy explicit zárd ki a <TargetFrameworks>-ben.

Gyakran ismételt kérdések

Használhatók még a Xamarin.Forms Renderer-ek .NET MAUI-ban?

Igen, van egy kompatibilitási réteg (Microsoft.Maui.Controls.Compatibility), amiben a régi Renderer-ek átmenetileg futnak, de ezt csak migráció alatt ajánlott használni. Új kódhoz mindenképpen Handler-t írj, mert a Renderer réteget a Microsoft „csak migrációs eszközként" pozicionálja, és a jövőben deprikálódhat.

Mikor használjunk PrependToMapping-et AppendToMapping helyett?

Akkor PrependToMapping, ha a MAUI beépített mapping delegate-je felülírná a te értékedet (mert később fut a láncban). Így a tiéd először beállít valamit, aztán a MAUI ráteszi a maga alapját. Az AppendToMapping a gyakoribb, akkor kell, ha a MAUI beállítja az alapokat, és utána finomítasz.

A Handler módosítás csak egy adott oldalon érvényes?

Nem. A Handler mapper globális állapot, ezért a módosítás az app egész életciklusán át, minden ilyen típusú vezérlőre hat. Ha oldal-specifikus viselkedést szeretnél, csinálj egy subclass-t (pl. MyEntry : Entry) és annak írj külön Handler-t, vagy használj Behavior-t.

Hogyan érem el a natív iOS UIView-ot vagy Android View-ot a Handlerben?

A handler.PlatformView property adja vissza. Típusa a Handler-ben megadott generikus paraméter (pl. UIButton iOS-en, MaterialButton Android-on). Innen bármely natív API-t közvetlenül hívhatsz. A cross-platform absztrakt viewhoz pedig a handler.VirtualView ad hozzáférést.

Kell-e Handler-t regisztrálni MauiProgram-ban a beépített MAUI vezérlők testreszabásához?

Nem. Ha csak beépített vezérlőt (Entry, Button, Label stb.) módosítasz AppendToMapping-gal, elég a mapper hívás a CreateMauiApp-ban. Regisztráció (AddHandler) csak akkor kell, ha teljesen új, saját cross-platform vezérlőt vezetsz be, aminek nincs MAUI beépített párja.

David O'Reilly
A Szerzőről David O'Reilly

Native iOS/Android specialist turned MAUI advocate. Writes about the gritty platform details most cross-platform tutorials skip.