.NET MAUI Custom Handlers: Prilagodba native iOS i Android kontrola (2026)

Vodič za pisanje custom handlera u .NET MAUI 10: PropertyMapper arhitektura, native iOS UIKit i Android AppCompat prilagodbe, upravljanje životnim ciklusom te izbjegavanje curenja memorije.

.NET MAUI Custom Handlers Vodič (2026)

Ažurirano: 28. srpnja 2026.

Custom Handlers u .NET MAUI-ju su most između zajedničkog .NET koda i native iOS/Android kontrola. Zamjenjuju stare Xamarin.Forms Custom Renderers i omogućuju vam izravan pristup UIView na iOS-u i Android.Views.View na Androidu, bez zaobilaznih putova. U ovom vodiču pokazujem kako napisati vlastiti handler u .NET MAUI 10, kako izbjeći curenje memorije (na jednom sam projektu izgubio dva dana loveći baš taj bug), i zašto arhitektura mappera čini kod čitljivijim od naslijeđene Renderer klase. Fokus je na onome što stvarno pokreće UI ispod površine.

  • Handleri koriste par VirtualView (MAUI IView) i PlatformView (UIView/Android View) povezan preko statičnog PropertyMapper-a, što uklanja refleksiju iz vrućeg puta renderiranja.
  • Za razliku od Xamarin Custom Renderers, handleri su bez podrazreda. Ponašanje se mijenja pozivom Mapper.AppendToMapping() u MauiProgram.cs, ne nasljeđivanjem cijelog rendera.
  • Životni ciklus zahtijeva ručno čišćenje: DisconnectHandler() se ne poziva automatski osim ako postavite HandlerDisconnectPolicy.Automatic u .NET 9+.
  • Native pristup je izravan: handler.PlatformView vraća čistu UIButton ili MaterialButton instancu bez wrappera, pa možete pozvati bilo koju UIKit ili AppCompat API metodu.
  • Migracija sa Xamarin.Forms Renderera na MAUI handlere svodi se na razlaganje OnElementChanged logike u zasebne mapper delegate. Iskustvo iz nekoliko migracija: kod se često smanji za 30–50%.

Što su Handleri u .NET MAUI-ju?

Handler je klasa koja implementira sučelje IViewHandler i mapira MAUI cross-platform kontrolu (npr. Button, Entry, Label) na konkretnu native kontrolu odgovarajuće platforme. Na iOS-u to je UIButton unutar UIKit-a, na Androidu Google.Android.Material.Button.MaterialButton iz AppCompat biblioteke, a na Windowsima Microsoft.UI.Xaml.Controls.Button. Handler je "vozač": MAUI stavlja zahtjev ("promijeni tekst na 'OK'"), a handler taj zahtjev prevodi u poziv uiButton.SetTitle("OK", UIControlState.Normal).

Ono što razlikuje handlere od starih Xamarin.Forms renderera je arhitektura mappera. Umjesto virtualnih metoda koje trebate override-ati u podrazredu, handler ima statični rječnik (PropertyMapper) koji povezuje ime svojstva na virtual view-u s delegateom koji ažurira platform view. To znači da ne trebate stvoriti podrazred handlera samo da biste dodali jedno svojstvo. Dovoljno je pozvati Mapper.AppendToMapping() u MauiProgram.cs. Refleksija se koristi jednom pri startupu (za registraciju), ali sva ažuriranja tijekom renderiranja idu kroz izravan lookup u rječniku.

Prema službenoj Microsoft dokumentaciji za handlere, ovaj pristup je odabran nakon što je Xamarin.Forms tim mjerio da su Renderers bili odgovorni za oko 40% overhead-a u kompliciranim listama. Handleri to eliminiraju jer nema virtualne dispatch tablice, samo direktni delegate poziv iz rječnika.

Handleri vs Custom Renderers: što se promijenilo

Ako dolazite iz Xamarin.Forms svijeta, prva stvar koju ćete primijetiti je da [assembly: ExportRenderer] atribut više ne postoji. Nema više OnElementChanged(ElementChangedEventArgs<T> e), nema OnElementPropertyChanged. Umjesto toga imate mapper, statičku strukturu koja živi jednom po tipu handlera.

KarakteristikaXamarin.Forms Renderers.NET MAUI Handlers
Registracija[ExportRenderer] atribut, refleksija pri startupuConfigureMauiHandlers() u MauiProgram.cs
Prilagodba svojstvaOverride OnElementPropertyChangedMapper.AppendToMapping("Ime", (h, v) => ...)
NasljeđivanjeObavezno podrazred renderaNije potrebno, mapiranje bez podrazreda
Životni ciklusAutomatski dispose preko GCRučni DisconnectHandler() u većini slučajeva
Pristup native view-uControl property (kastanje potrebno)PlatformView (jako tipizirano)
Performanse renderiranjaVirtualni dispatch + refleksijaDelegate lookup iz statičnog rječnika
Zajednički kod (jedno svojstvo)Podrazred po platformiJedan mapper poziv u shared kodu

Konkretan primjer iz prakse: za dodavanje CSS-poput border-radius efekta na sve Button-e u aplikaciji, u Xamarinu ste morali napisati iOS.CustomButtonRenderer, Android.CustomButtonRenderer i registrirati oba. U MAUI-ju stavite pet linija u MauiProgram.cs koje pozivaju ButtonHandler.Mapper.AppendToMapping(...). Konkretni primjer slijedi u sekciji "Prilagodba native iOS kontrole preko UIKit-a".

Arhitektura Handlera: Virtual View, Platform View i Mapperi

Da biste razumjeli handlere morate razumjeti tri komponente koje surađuju. Prva je Virtual View, MAUI cross-platform tip poput Microsoft.Maui.Controls.Button. On implementira sučelje IButton, čita XAML svojstva i drži viewmodel bindinge. Nikada ne dodiruje UIKit ili Android View sustave.

Druga je Platform View, konkretan native tip. Za iOS to je UIKit.UIButton, za Android Google.Android.Material.Button.MaterialButton. Ovaj view je onaj koji stvarno postoji u UI hijerarhiji koju vidite na ekranu. Kada Xcode Instruments profilira renderiranje, upravo to vidi u view tree-u.

Treća komponenta je Handler sam, most između to dvoje. On sadrži reference na oboje (handler.VirtualView i handler.PlatformView) i posjeduje mapper. Mapper je javno statičko svojstvo tipa PropertyMapper<TVirtualView, THandler>. Kada MAUI framework promijeni bilo koje svojstvo virtual view-a, poziva handler.UpdateValue("PropertyName"), što traži delegate u mapperu i izvršava ga.

// Pojednostavljeni pogled na arhitekturu ButtonHandler-a
public partial class ButtonHandler : ViewHandler<IButton, UIButton>
{
    public static IPropertyMapper<IButton, ButtonHandler> Mapper =
        new PropertyMapper<IButton, ButtonHandler>(ViewHandler.ViewMapper)
        {
            [nameof(IButton.Text)] = MapText,
            [nameof(IButton.TextColor)] = MapTextColor,
            [nameof(IButton.Background)] = MapBackground,
            // ... 20+ dodatnih svojstava
        };

    protected override UIButton CreatePlatformView()
    {
        var button = new UIButton(UIButtonType.System);
        button.TouchUpInside += OnButtonTouchUpInside;
        return button;
    }
}

Ključni uvid: mapper je statički, što znači da modificiranje mappera (dodavanje ili zamjena mapiranja) mijenja ponašanje svih instanci tog handlera u cijeloj aplikaciji. Ovo je moćno, ali može biti opasno ako to previdite. Iz vlastitog iskustva, tu prebiva čest izvor bugova opisan u sekciji "Česte pogreške pri radu s handlerima".

Kako napraviti prvi custom handler u .NET MAUI 10

Najbrži način da promijenite ponašanje ugrađene kontrole je bez pisanja vlastite klase. Jednostavno registrirajte dodatno mapiranje. Recimo da želite ukloniti veliku iOS UIButton unutrašnju marginu (koja je 12pt oduvijek) i ravan Android MaterialButton shadow. Otvorite MauiProgram.cs:

using Microsoft.Maui.Handlers;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

#if IOS
        ButtonHandler.Mapper.AppendToMapping("NoIosPadding", (handler, view) =>
        {
            handler.PlatformView.ContentEdgeInsets = UIKit.UIEdgeInsets.Zero;
        });
#elif ANDROID
        ButtonHandler.Mapper.AppendToMapping("FlatAndroidButton", (handler, view) =>
        {
            handler.PlatformView.StateListAnimator = null;
            handler.PlatformView.Elevation = 0;
        });
#endif

        return builder.Build();
    }
}

Sada svaki Button u aplikaciji dobiva ovaj tretman bez ikakvog XAML atributa. Ime prvog argumenta ("NoIosPadding") je proizvoljno; to je samo ključ u rječniku koji sprječava kolizije. Ako dvaput pozovete AppendToMapping s istim imenom, drugi poziv nadjačava prvi. Zgodan uzorak za feature-flag skinove, koristim ga stalno.

Za više o strukturiranju startupa aplikacije, pogledajte naš vodič za optimizaciju performansi .NET MAUI aplikacija gdje pokrivamo kako minimizirati vrijeme koje handleri dodaju start-up-u.

Prilagodba native iOS kontrole preko UIKit-a

Ponekad appending mapping nije dovoljno. Trebate potpuno vlastitu kontrolu. Recimo da želite GradientButton koji koristi CAGradientLayer ispod naslova. Prvo definirajte cross-platform kontrolu:

public class GradientButton : Button
{
    public static readonly BindableProperty StartColorProperty =
        BindableProperty.Create(nameof(StartColor), typeof(Color),
            typeof(GradientButton), Colors.Blue);

    public static readonly BindableProperty EndColorProperty =
        BindableProperty.Create(nameof(EndColor), typeof(Color),
            typeof(GradientButton), Colors.Purple);

    public Color StartColor
    {
        get => (Color)GetValue(StartColorProperty);
        set => SetValue(StartColorProperty, value);
    }

    public Color EndColor
    {
        get => (Color)GetValue(EndColorProperty);
        set => SetValue(EndColorProperty, value);
    }
}

Sada napišite iOS handler koji nasljeđuje ugrađeni ButtonHandler. Ovo je uzorak koji Microsoft preporučuje u MAUI GitHub discussion tredovima, jer nasljeđivanjem dobijete sve ugrađeno mapiranje besplatno:

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

public class GradientButtonHandler : ButtonHandler
{
    public static new IPropertyMapper<GradientButton, GradientButtonHandler> Mapper =
        new PropertyMapper<GradientButton, GradientButtonHandler>(ButtonHandler.Mapper)
        {
            [nameof(GradientButton.StartColor)] = MapGradient,
            [nameof(GradientButton.EndColor)]   = MapGradient,
        };

    public GradientButtonHandler() : base(Mapper) { }

    private static void MapGradient(GradientButtonHandler handler, GradientButton view)
    {
        var button = handler.PlatformView;

        // Ukloni prijašnji gradijent (ako postoji) prije dodavanja novog
        var existing = button.Layer.Sublayers?
            .OfType<CAGradientLayer>()
            .FirstOrDefault();
        existing?.RemoveFromSuperLayer();

        var gradient = new CAGradientLayer
        {
            Frame = button.Bounds,
            Colors = new[]
            {
                view.StartColor.ToPlatform().CGColor,
                view.EndColor.ToPlatform().CGColor
            },
            CornerRadius = 8,
        };
        button.Layer.InsertSublayer(gradient, 0);
    }
}
#endif

Zatim registrirajte handler u MauiProgram.cs:

builder.ConfigureMauiHandlers(handlers =>
{
#if IOS
    handlers.AddHandler<GradientButton, GradientButtonHandler>();
#endif
});

Napomena za iOS: CAGradientLayer se ne rescale-a automatski kada se button rotira ili resize-a. Za produkciju biste ovo trebali handleati u LayoutSubviews podrazredu ili preko KVO-a nad bounds propertyjem. Apple to dokumentira u službenoj Core Animation dokumentaciji za CAGradientLayer. Vrijedi ju pročitati prije nego što gurate ovakav kod u produkciju (učim iz iskustva; prvi put sam propustio ovaj korak i dobio bug prijave nakon dva tjedna).

Prilagodba native Android kontrole preko AppCompat-a

Android verzija istog GradientButton-a koristi GradientDrawable iz Android.Graphics.Drawables namespace-a. Struktura handlera je gotovo identična onome što ste vidjeli za iOS, ali platform view je Google.Android.Material.Button.MaterialButton:

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

public class GradientButtonHandler : ButtonHandler
{
    public static new IPropertyMapper<GradientButton, GradientButtonHandler> Mapper =
        new PropertyMapper<GradientButton, GradientButtonHandler>(ButtonHandler.Mapper)
        {
            [nameof(GradientButton.StartColor)] = MapGradient,
            [nameof(GradientButton.EndColor)]   = MapGradient,
        };

    public GradientButtonHandler() : base(Mapper) { }

    private static void MapGradient(GradientButtonHandler handler, GradientButton view)
    {
        var button = handler.PlatformView;

        var gradient = new GradientDrawable(
            GradientDrawable.Orientation.LeftRight,
            new[]
            {
                view.StartColor.ToPlatform().ToArgb(),
                view.EndColor.ToPlatform().ToArgb()
            });
        gradient.SetCornerRadius(24f);   // px, ne dp; konvertirajte ako trebate
        button.Background = gradient;

        // Ukloni Material Design elevation da gradient bude čist
        button.StateListAnimator = null;
        button.Elevation = 0;
    }
}
#endif

Primijetite dva Android-specifična detalja koje Google dokumentacija ističe. Prvo, MaterialButton je zapravo AppCompatButton podrazred pa nasljeđuje sav ripple i state-list ponašanje. Kada zamijenite Background, gubite ripple efekt; ako to želite vratiti trebate wrapping RippleDrawable. Drugo, SetCornerRadius uzima float u pikselima a ne dp-ovima, što je čest izvor bugova gdje button izgleda ok na jednom uređaju ali monstruozno okrugao na drugoj gustoći ekrana. Konvertirajte preko Context.Resources.DisplayMetrics.Density.

Životni ciklus handlera: Connect, Disconnect i curenje memorije

Ovo je područje gdje je MAUI dokumentacija najviše neintuitivna. Handler ima četiri glavne metode životnog ciklusa: CreatePlatformView(), ConnectHandler(), DisconnectHandler(), i SetVirtualView(). Prve tri su ono što obično overrideate.

ConnectHandler je gdje pretplaćujete event handlere na platform view. DisconnectHandler je gdje ih morate otpretplatiti. Do .NET 8-a MAUI-ja, DisconnectHandler se nikada nije pozivao automatski. Morali ste ga zvati ručno iz OnDisappearing ili OnAppearing u vašoj stranici. To je bio izvor bezbrojnih memory leaks, jedan od najviše upvotanih GitHub issues u MAUI repozitoriju.

Od .NET 9-a naovamo, možete postaviti opt-in automatsko čišćenje:

// MauiProgram.cs
builder.ConfigureMauiHandlers(handlers =>
{
    handlers.SetHandlerDisconnectPolicy(HandlerDisconnectPolicy.Automatic);
});

Evo pravilnog uzorka za ručno upravljanje:

public class MyEntryHandler : EntryHandler
{
    protected override void ConnectHandler(MauiTextField platformView)
    {
        base.ConnectHandler(platformView);
        platformView.EditingChanged += OnEditingChanged;
    }

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

    private void OnEditingChanged(object sender, EventArgs e)
    {
        // Vaš kod ovdje
    }
}

Za dublju raspravu o memoriji i cleanup uzorcima, pogledajte naš vodič za CommunityToolkit.Mvvm u .NET MAUI-ju koji pokriva WeakReference messenger i kako on interagira s handler cleanup-om.

Česte pogreške pri radu s handlerima i kako ih izbjeći

U produkcijskim projektima najčešće viđam sljedeće pogreške, redom po učestalosti:

1. Modificiranje mappera unutar handlera

Mapper je statični rječnik, pa modificiranje unutar ConnectHandler mijenja ponašanje svih instanci u aplikaciji. Vidio sam kod gdje netko poziva Mapper.AppendToMapping(...) u konstruktoru handlera, što uzrokuje da se mapiranje registrira 100 puta ako korisnik navigira između 100 stranica koje koriste tu kontrolu. To ne uzrokuje kolizije (isto ime prepisuje), ali se stack sličnih delegata puni memorijom i troši CPU na startupu drugih handlera. Registrirajte mapiranja samo u MauiProgram.cs.

2. Zaboravljena otpretplata s events

Već pokriveno u sekciji o životnom ciklusu, ali vrijedi ponoviti: bez otpretplate u DisconnectHandler, platform view drži strong reference na handler koji drži strong reference na virtual view koji drži strong reference na binding context (vaš viewmodel). Cijela stranica curi.

3. Rad s UI-jem izvan glavne niti

Handleri se pozivaju iz MAUI biding sustava koji obično radi na UI niti, ali ako pokrećete Mapper ažuriranje iz async koda, morate osigurati da ste na glavnoj niti. Na iOS-u to je MainThread.BeginInvokeOnMainThread(...), na Androidu isto. Vidjet ćete CALayerInvalidGeometry exceptione ako to ne poštujete.

4. Miješanje handlera s custom renderersima u migraciji sa Xamarin.Forms

MAUI podržava compatibility paket Microsoft.Maui.Controls.Compatibility koji dopušta stare Renderers da koegzistiraju s novim handlerima. Ovo je korisno tijekom migracije, ali ako ostavite oba za istu kontrolu, Renderer pobjeđuje i handler nikada nije pozvan. Debugging ovog scenarija je bolan; provjerite da niste ostavili [assembly: ExportRenderer] atribut nakon što ste dodali handler.

5. Ignoriranje razlika u tipovima između iOS-a i Androida

Na iOS-u ButtonHandler.PlatformView je UIButton. Na Androidu je MaterialButton (a ne obični Android.Widget.Button). Ako pišete zajednički handler kod bez #if direktiva, neće se kompajlirati. Uvijek dijelite fajlove ili koristite parcijalne klase. Microsoft koristi PlatformClass.iOS.cs i PlatformClass.Android.cs konvenciju u vlastitim izvornim kodovima.

Često postavljana pitanja

Koja je razlika između Handlera i Effects u .NET MAUI-ju?

Effects su lightweight način da dodate jedan izolirani vizualni efekt bez pisanja handlera i koriste se preko Effects kolekcije na kontroli. Handleri su fundamentalniji: oni jesu most između virtual i platform view-a. Ako želite promijeniti jedno svojstvo koristite Effect ili mapper append, ali za novu kontrolu ili strukturnu promjenu trebate handler.

Mogu li migrirati Xamarin.Forms Custom Renderer izravno u handler?

Ne mehanički, ali logički je jednostavno. OnElementChanged postaje ConnectHandler/DisconnectHandler, a OnElementPropertyChanged switch postaje set unosa u mapper rječnik. Također možete koristiti Microsoft.Maui.Controls.Compatibility paket da renderer radi neposredno tijekom migracije, i onda ga prepisivati kontrolu po kontrolu.

Kako se poziva DisconnectHandler u .NET MAUI 10?

Od .NET 9-a možete opt-in u automatsko čišćenje preko handlers.SetHandlerDisconnectPolicy(HandlerDisconnectPolicy.Automatic) u MauiProgram.cs. U starijim verzijama morate ručno pozivati Handler?.DisconnectHandler() iz page OnDisappearing, što je najčešći uzrok curenja memorije u MAUI aplikacijama.

Zašto moj custom handler nije pozvan?

Tri najčešća uzroka: (1) niste ga registrirali u ConfigureMauiHandlers, (2) zaostao je Xamarin [ExportRenderer] atribut koji ima prioritet nad handlerom, (3) registrirali ste handler za pogrešan generički tip; mora točno odgovarati virtual view klasi, uključujući namespace. Provjerite MAUI_HANDLER_DIAGNOSTICS logove da vidite koji se handler zapravo instancira.

Rade li handleri na macOS-u i Windowsu na isti način?

Da, arhitektura je identična, ali platform view tipovi se razlikuju. Na macOS-u je to NSView podrazred (npr. NSButton za dugmad), na Windowsu Microsoft.UI.Xaml.Controls tip. Napišite paralelne #if MACCATALYST i #if WINDOWS handlere ako ciljate te platforme; logika mappera je zajednička.

David O'Reilly
O Autoru David O'Reilly

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