.NET MAUI Handlers en Platform-Specifieke Integratie
Als je cross-platform mobiele apps bouwt met .NET MAUI, dan ontkom je er niet aan: vroeg of laat moet je de diepte in met handlers. En eerlijk gezegd, dat is ook precies waar het interessant wordt. Met de introductie van .NET MAUI heeft Microsoft het oude renderer-patroon uit Xamarin.Forms volledig vervangen door de handler-architectuur — en dat is een enorme verbetering. Je krijgt meer controle, betere prestaties en een veel schonere scheiding tussen je cross-platform logica en platform-specifieke implementaties.
In dit artikel nemen we de handler-architectuur grondig door. We kijken hoe handlers werken, hoe je bestaande controls kunt aanpassen, hoe je volledig nieuwe custom controls bouwt, en hoe je native platform-API's benadert. Of je nu van Xamarin.Forms komt of helemaal nieuw bent in .NET MAUI — na dit artikel heb je een stevige basis.
De Handler-architectuur begrijpen
Waarom stapte Microsoft eigenlijk af van renderers? Kort gezegd: renderers waren nauw gekoppeld aan hun cross-platform controls. Dat leidde tot complexe overerving, moeilijk te onderhouden code en prestatieproblemen. Wilde je één eigenschap aanpassen? Dan moest je toch de hele renderer overschrijven. Niet ideaal.
Handlers pakken dat anders aan. Ze introduceren een lichtgewicht, losjes gekoppeld patroon dat op drie kernconcepten draait:
- Virtuele weergaven (Virtual Views) — De cross-platform abstractie van een control, gedefinieerd als een interface. Zo beschrijft
IEntrywat een tekstinvoerveld is, ongeacht het platform. - PropertyMapper — Een soort woordenboek dat eigenschappen van de virtuele weergave koppelt aan methoden die de bijbehorende native eigenschap bijwerken. Verandert de
Text-eigenschap? Dan roept de PropertyMapper automatisch de juiste methode aan. - CommandMapper — Hetzelfde principe als PropertyMapper, maar dan voor acties en commando's. Denk aan
FocusofUnfocusdie op het native control aangeroepen moeten worden.
Hoe de handler-pijplijn werkt
Wanneer .NET MAUI een control op het scherm wil tonen, doorloopt het deze stappen:
- Het framework kijkt welke handler geregistreerd is voor het type control (bijvoorbeeld
EntryHandlervoorEntry). - De handler maakt een native weergave aan — een
EditTextop Android, eenUITextFieldop iOS. - De PropertyMapper wordt doorlopen om alle initiële eigenschapswaarden naar de native weergave te kopiëren.
- Bij elke eigendomswijziging wordt alleen de relevante mapping-methode aangeroepen. De gehele control hoeft dus niet opnieuw opgebouwd te worden.
Laten we eens kijken naar hoe zo'n handler er in de basis uitziet:
public partial class EntryHandler : ViewHandler<IEntry, MauiTextField>
{
public static IPropertyMapper<IEntry, EntryHandler> Mapper =
new PropertyMapper<IEntry, EntryHandler>(ViewMapper)
{
[nameof(IEntry.Text)] = MapText,
[nameof(IEntry.TextColor)] = MapTextColor,
[nameof(IEntry.Placeholder)] = MapPlaceholder,
[nameof(IEntry.Font)] = MapFont,
[nameof(IEntry.IsReadOnly)] = MapIsReadOnly,
[nameof(IEntry.MaxLength)] = MapMaxLength,
};
public static CommandMapper<IEntry, EntryHandler> CommandMapper =
new(ViewCommandMapper)
{
["Focus"] = MapFocus,
};
public EntryHandler() : base(Mapper, CommandMapper)
{
}
}
Dat partial-sleutelwoord is hier cruciaal. Het maakt het mogelijk om platform-specifieke implementaties in aparte bestanden te plaatsen, terwijl ze dezelfde klasse delen. De CreatePlatformView-methode wordt per platform apart geïmplementeerd en geeft het daadwerkelijke native control terug.
Het verschil tussen ViewHandler en andere basisklassen
.NET MAUI biedt verschillende basisklassen voor handlers, afhankelijk van wat je wilt bouwen:
- ViewHandler<TVirtualView, TPlatformView> — De meest gebruikte. Geschikt voor standaard visuele controls.
- ElementHandler<TVirtualView, TPlatformView> — Voor niet-visuele elementen die toch een platform-representatie nodig hebben.
- ContentViewHandler — Specifiek voor controls die onderliggende inhoud bevatten.
Bestaande controls aanpassen met handlers
Dit is eerlijk gezegd een van de dingen waar ik het meest enthousiast over ben. Je kunt bestaande controls aanpassen zonder een volledige custom handler te schrijven. Dat scheelt een hoop boilerplate. Je hebt drie methoden tot je beschikking:
- PrependToMapping — Voert code uit vóór de standaard mapping.
- ModifyMapping — Vervangt of wijzigt de bestaande mapping voor een eigenschap.
- AppendToMapping — Voert code uit na de standaard mapping.
Voorbeeld: de onderlijn van een Entry verwijderen op Android
Dit is zo'n typisch voorbeeld waar je als Android-ontwikkelaar tegenaan loopt. Die standaard onderlijn onder een Entry-control past gewoon niet in elk design. Met handlers is het gelukkig simpel op te lossen:
// In MauiProgram.cs of in de constructor van een pagina
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(
"NoUnderline", (handler, view) =>
{
#if ANDROID
handler.PlatformView.BackgroundTintList =
Android.Content.Res.ColorStateList.ValueOf(
Android.Graphics.Color.Transparent);
#endif
});
Dit past alle Entry-controls in je app aan. De conditionele compilatie met #if ANDROID zorgt ervoor dat de Android-specifieke code alleen voor dat platform wordt gecompileerd.
Voorbeeld: afgeronde hoeken voor een Frame op iOS
Wil je de hoekradius van alle Frame-controls op iOS aanpassen? Ook dat is een paar regels werk:
Microsoft.Maui.Handlers.BorderHandler.Mapper.AppendToMapping(
"RoundedBorder", (handler, view) =>
{
#if IOS
handler.PlatformView.Layer.CornerRadius = 16;
handler.PlatformView.Layer.MasksToBounds = true;
handler.PlatformView.Layer.BorderColor = UIKit.UIColor.SystemGray5.CGColor;
handler.PlatformView.Layer.BorderWidth = 1;
#endif
});
Voorbeeld: aanpassing per specifiek control-exemplaar
Soms wil je niet álle controls van een type aanpassen, maar slechts een specifiek exemplaar. Dat kan ook. Je controleert dan binnen de mapping op een eigenschap van de weergave:
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(
"CustomEntry", (handler, view) =>
{
if (view is Entry entry && entry.StyleClass?.Contains("search-entry") == true)
{
#if ANDROID
handler.PlatformView.SetBackgroundColor(Android.Graphics.Color.White);
handler.PlatformView.SetPadding(40, 20, 40, 20);
#elif IOS
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
handler.PlatformView.BackgroundColor = UIKit.UIColor.White;
#endif
}
});
In XAML ken je dan de stijlklasse toe aan het specifieke control:
<Entry StyleClass="search-entry"
Placeholder="Zoeken..."
Margin="10" />
Een volledig aangepast control bouwen
Oké, nu wordt het echt leuk. Wanneer de ingebouwde controls niet voldoen, kun je een volledig nieuw custom control bouwen met een eigen handler. We bouwen hier stap voor stap een RatingControl — een sterrenbeoordelingswidget waarmee gebruikers een waardering van 1 tot 5 kunnen geven. Dit is een mooi voorbeeld omdat het zowel visuele weergave als gebruikersinteractie combineert.
Stap 1: het cross-platform control definiëren
Eerst maken we de abstracte definitie van ons control. Dit is de klasse die ontwikkelaars in hun XAML en C#-code gaan gebruiken:
// Controls/RatingControl.cs
namespace MijnApp.Controls;
public class RatingControl : View
{
// Bindable eigenschap voor de huidige waardering
public static readonly BindableProperty RatingProperty =
BindableProperty.Create(
nameof(Rating),
typeof(int),
typeof(RatingControl),
defaultValue: 0,
propertyChanged: (bindable, oldValue, newValue) =>
{
if (bindable is RatingControl control)
control.RatingChanged?.Invoke(control,
new RatingChangedEventArgs((int)newValue));
});
public int Rating
{
get => (int)GetValue(RatingProperty);
set => SetValue(RatingProperty, value);
}
// Bindable eigenschap voor het maximum aantal sterren
public static readonly BindableProperty MaxRatingProperty =
BindableProperty.Create(
nameof(MaxRating),
typeof(int),
typeof(RatingControl),
defaultValue: 5);
public int MaxRating
{
get => (int)GetValue(MaxRatingProperty);
set => SetValue(MaxRatingProperty, value);
}
// Bindable eigenschap voor de sterkleur
public static readonly BindableProperty StarColorProperty =
BindableProperty.Create(
nameof(StarColor),
typeof(Color),
typeof(RatingControl),
defaultValue: Colors.Gold);
public Color StarColor
{
get => (Color)GetValue(StarColorProperty);
set => SetValue(StarColorProperty, value);
}
// Bindable eigenschap voor de lege sterkleur
public static readonly BindableProperty EmptyStarColorProperty =
BindableProperty.Create(
nameof(EmptyStarColor),
typeof(Color),
typeof(RatingControl),
defaultValue: Colors.LightGray);
public Color EmptyStarColor
{
get => (Color)GetValue(EmptyStarColorProperty);
set => SetValue(EmptyStarColorProperty, value);
}
// Bindable eigenschap voor alleen-lezen modus
public static readonly BindableProperty IsReadOnlyProperty =
BindableProperty.Create(
nameof(IsReadOnly),
typeof(bool),
typeof(RatingControl),
defaultValue: false);
public bool IsReadOnly
{
get => (bool)GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
// Gebeurtenis voor waarderingswijzigingen
public event EventHandler<RatingChangedEventArgs>? RatingChanged;
}
public class RatingChangedEventArgs : EventArgs
{
public int NewRating { get; }
public RatingChangedEventArgs(int newRating)
{
NewRating = newRating;
}
}
Stap 2: de PropertyMapper en CommandMapper definiëren
Nu definiëren we de handler met zijn mappers. We gebruiken weer een partial class zodat de platform-specifieke implementatie in eigen bestanden kan:
// Handlers/RatingControlHandler.cs
using Microsoft.Maui.Handlers;
using MijnApp.Controls;
namespace MijnApp.Handlers;
public partial class RatingControlHandler : ViewHandler<RatingControl, PlatformRatingView>
{
public static IPropertyMapper<RatingControl, RatingControlHandler> PropertyMapper =
new PropertyMapper<RatingControl, RatingControlHandler>(ViewMapper)
{
[nameof(RatingControl.Rating)] = MapRating,
[nameof(RatingControl.MaxRating)] = MapMaxRating,
[nameof(RatingControl.StarColor)] = MapStarColor,
[nameof(RatingControl.EmptyStarColor)] = MapEmptyStarColor,
[nameof(RatingControl.IsReadOnly)] = MapIsReadOnly,
};
public static CommandMapper<RatingControl, RatingControlHandler> CommandMapper =
new(ViewCommandMapper);
public RatingControlHandler() : base(PropertyMapper, CommandMapper)
{
}
}
Stap 3: de Android-implementatie
Voor Android bouwen we een native weergave op basis van een LinearLayout met ImageView-elementen voor elke ster. Het is wat meer code, maar het geeft je volledige controle over hoe de sterren worden weergegeven:
// Platforms/Android/Handlers/RatingControlHandler.cs
using Android.Views;
using Android.Widget;
using AndroidX.Core.Content;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using MijnApp.Controls;
namespace MijnApp.Handlers;
// PlatformRatingView is een alias voor het native Android-type
using PlatformRatingView = Android.Widget.LinearLayout;
public partial class RatingControlHandler : ViewHandler<RatingControl, PlatformRatingView>
{
private readonly List<ImageView> _starViews = new();
protected override PlatformRatingView CreatePlatformView()
{
var layout = new LinearLayout(Context)
{
Orientation = Orientation.Horizontal,
};
return layout;
}
protected override void ConnectHandler(PlatformRatingView platformView)
{
base.ConnectHandler(platformView);
RebuildStars();
}
protected override void DisconnectHandler(PlatformRatingView platformView)
{
foreach (var star in _starViews)
{
star.Click -= OnStarClicked;
star.Dispose();
}
_starViews.Clear();
base.DisconnectHandler(platformView);
}
private void RebuildStars()
{
PlatformView.RemoveAllViews();
_starViews.Clear();
var maxRating = VirtualView?.MaxRating ?? 5;
var sizeInDp = (int)(32 * Context.Resources.DisplayMetrics.Density);
for (int i = 0; i < maxRating; i++)
{
var starView = new ImageView(Context);
var layoutParams = new LinearLayout.LayoutParams(sizeInDp, sizeInDp)
{
RightMargin = (int)(4 * Context.Resources.DisplayMetrics.Density)
};
starView.LayoutParameters = layoutParams;
starView.Tag = i + 1;
starView.Click += OnStarClicked;
_starViews.Add(starView);
PlatformView.AddView(starView);
}
UpdateStarColors();
}
private void OnStarClicked(object? sender, EventArgs e)
{
if (VirtualView?.IsReadOnly == true) return;
if (sender is ImageView imageView && imageView.Tag is int rating)
{
VirtualView.Rating = rating;
}
}
private void UpdateStarColors()
{
if (VirtualView == null) return;
var rating = VirtualView.Rating;
var starColor = VirtualView.StarColor.ToPlatform();
var emptyColor = VirtualView.EmptyStarColor.ToPlatform();
for (int i = 0; i < _starViews.Count; i++)
{
var drawable = ContextCompat.GetDrawable(Context,
i < rating
? Android.Resource.Drawable.ButtonStarBigOn
: Android.Resource.Drawable.ButtonStarBigOff);
drawable?.SetTint(i < rating ? starColor : emptyColor);
_starViews[i].SetImageDrawable(drawable);
}
}
public static void MapRating(RatingControlHandler handler, RatingControl view)
{
handler.UpdateStarColors();
}
public static void MapMaxRating(RatingControlHandler handler, RatingControl view)
{
handler.RebuildStars();
}
public static void MapStarColor(RatingControlHandler handler, RatingControl view)
{
handler.UpdateStarColors();
}
public static void MapEmptyStarColor(RatingControlHandler handler, RatingControl view)
{
handler.UpdateStarColors();
}
public static void MapIsReadOnly(RatingControlHandler handler, RatingControl view)
{
foreach (var star in handler._starViews)
{
star.Clickable = !view.IsReadOnly;
}
}
}
Stap 4: de iOS-implementatie
Voor iOS gebruiken we een UIStackView met UIButton-elementen. Het mooie aan iOS is dat je SF Symbols kunt gebruiken voor de sterren, wat een echt native look geeft:
// Platforms/iOS/Handlers/RatingControlHandler.cs
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using MijnApp.Controls;
using UIKit;
namespace MijnApp.Handlers;
using PlatformRatingView = UIKit.UIStackView;
public partial class RatingControlHandler : ViewHandler<RatingControl, PlatformRatingView>
{
private readonly List<UIButton> _starButtons = new();
protected override PlatformRatingView CreatePlatformView()
{
var stackView = new UIStackView
{
Axis = UILayoutConstraintAxis.Horizontal,
Distribution = UIStackViewDistribution.FillEqually,
Spacing = 4,
Alignment = UIStackViewAlignment.Center,
};
return stackView;
}
protected override void ConnectHandler(PlatformRatingView platformView)
{
base.ConnectHandler(platformView);
RebuildStars();
}
protected override void DisconnectHandler(PlatformRatingView platformView)
{
foreach (var button in _starButtons)
{
button.TouchUpInside -= OnStarTapped;
button.Dispose();
}
_starButtons.Clear();
base.DisconnectHandler(platformView);
}
private void RebuildStars()
{
foreach (var arranged in PlatformView.ArrangedSubviews)
{
PlatformView.RemoveArrangedSubview(arranged);
arranged.RemoveFromSuperview();
}
_starButtons.Clear();
var maxRating = VirtualView?.MaxRating ?? 5;
var config = UIImage.SymbolConfiguration.Create(24, UIImageSymbolWeight.Medium);
for (int i = 0; i < maxRating; i++)
{
var button = new UIButton(UIButtonType.Custom);
button.Tag = i + 1;
button.TouchUpInside += OnStarTapped;
var emptyImage = UIImage.GetSystemImage("star", config);
var filledImage = UIImage.GetSystemImage("star.fill", config);
button.SetImage(emptyImage, UIControlState.Normal);
button.SetImage(filledImage, UIControlState.Selected);
_starButtons.Add(button);
PlatformView.AddArrangedSubview(button);
}
UpdateStarAppearance();
}
private void OnStarTapped(object? sender, EventArgs e)
{
if (VirtualView?.IsReadOnly == true) return;
if (sender is UIButton button)
{
VirtualView.Rating = (int)button.Tag;
}
}
private void UpdateStarAppearance()
{
if (VirtualView == null) return;
var rating = VirtualView.Rating;
var starColor = VirtualView.StarColor.ToPlatform();
var emptyColor = VirtualView.EmptyStarColor.ToPlatform();
for (int i = 0; i < _starButtons.Count; i++)
{
var isSelected = i < rating;
_starButtons[i].Selected = isSelected;
_starButtons[i].TintColor = isSelected ? starColor : emptyColor;
}
}
public static void MapRating(RatingControlHandler handler, RatingControl view)
{
handler.UpdateStarAppearance();
}
public static void MapMaxRating(RatingControlHandler handler, RatingControl view)
{
handler.RebuildStars();
}
public static void MapStarColor(RatingControlHandler handler, RatingControl view)
{
handler.UpdateStarAppearance();
}
public static void MapEmptyStarColor(RatingControlHandler handler, RatingControl view)
{
handler.UpdateStarAppearance();
}
public static void MapIsReadOnly(RatingControlHandler handler, RatingControl view)
{
foreach (var button in handler._starButtons)
{
button.UserInteractionEnabled = !view.IsReadOnly;
}
}
}
Stap 5: de handler registreren in MauiProgram
Het laatste puzzelstukje — en dit vergeten mensen nog weleens — is het registreren van de handler in het opstartproces:
// MauiProgram.cs
using MijnApp.Controls;
using MijnApp.Handlers;
namespace MijnApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler<RatingControl, RatingControlHandler>();
})
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
return builder.Build();
}
}
En dan kun je het control gewoon in je XAML gebruiken:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:MijnApp.Controls"
x:Class="MijnApp.ProductDetailPage"
Title="Productbeoordeling">
<VerticalStackLayout Padding="20" Spacing="15">
<Label Text="Geef uw beoordeling:"
FontSize="18"
FontAttributes="Bold" />
<controls:RatingControl x:Name="ratingControl"
Rating="{Binding UserRating, Mode=TwoWay}"
MaxRating="5"
StarColor="Gold"
EmptyStarColor="LightGray"
RatingChanged="OnRatingChanged"
HeightRequest="40" />
<Label x:Name="ratingLabel"
Text="Nog geen beoordeling gegeven"
FontSize="14"
TextColor="Gray" />
<!-- Alleen-lezen weergave voor gemiddelde beoordeling -->
<Label Text="Gemiddelde beoordeling:" FontSize="16" />
<controls:RatingControl Rating="4"
MaxRating="5"
StarColor="Orange"
IsReadOnly="True"
HeightRequest="32" />
</VerticalStackLayout>
</ContentPage>
Platformspecifieke code aanroepen
.NET MAUI geeft je meerdere manieren om platformspecifieke code te schrijven. Elke benadering heeft z'n eigen voor- en nadelen, en welke je kiest hangt af van de complexiteit en je architectuurkeuzes. Laten we ze doorlopen.
Benadering 1: conditionele compilatie
De meest directe manier. Ideaal voor kleine, geïsoleerde stukjes platformcode waar je geen aparte bestanden voor wilt aanmaken:
public void ConfigureerStatusBalk()
{
#if ANDROID
var activiteit = Platform.CurrentActivity;
if (activiteit?.Window != null)
{
activiteit.Window.SetStatusBarColor(
Android.Graphics.Color.ParseColor("#1E3A5F"));
activiteit.Window.DecorView.SystemUiVisibility =
(Android.Views.StatusBarVisibility)
Android.Views.SystemUiFlags.LightStatusBar;
}
#elif IOS
var statusBarFrame = UIKit.UIApplication.SharedApplication
.StatusBarFrame;
var statusBarView = new UIKit.UIView(statusBarFrame);
statusBarView.BackgroundColor = UIKit.UIColor.FromRGB(30, 58, 95);
UIKit.UIApplication.SharedApplication
.KeyWindow?.AddSubview(statusBarView);
#endif
}
Het voordeel? Simpel en snel. Het nadeel? Bij uitgebreide platformlogica wordt het al gauw onoverzichtelijk, en het maakt testen lastiger.
Benadering 2: partiële klassen en methoden
Een elegantere oplossing. Je definieert de gedeelde interface in een gemeenschappelijk bestand en implementeert de platformspecifieke logica apart per platform:
// Services/DeviceOrientationService.cs (gedeeld)
namespace MijnApp.Services;
public partial class DeviceOrientationService
{
public partial DeviceOrientation GetCurrentOrientation();
}
public enum DeviceOrientation
{
Unknown,
Portrait,
Landscape
}
// Platforms/Android/Services/DeviceOrientationService.cs
using Android.Content;
using Android.Runtime;
using Android.Views;
namespace MijnApp.Services;
public partial class DeviceOrientationService
{
public partial DeviceOrientation GetCurrentOrientation()
{
var context = Android.App.Application.Context;
var windowManager = context.GetSystemService(Context.WindowService)
.JavaCast<IWindowManager>();
var rotation = windowManager?.DefaultDisplay?.Rotation;
return rotation switch
{
SurfaceOrientation.Rotation0 or
SurfaceOrientation.Rotation180 => DeviceOrientation.Portrait,
SurfaceOrientation.Rotation90 or
SurfaceOrientation.Rotation270 => DeviceOrientation.Landscape,
_ => DeviceOrientation.Unknown,
};
}
}
// Platforms/iOS/Services/DeviceOrientationService.cs
using UIKit;
namespace MijnApp.Services;
public partial class DeviceOrientationService
{
public partial DeviceOrientation GetCurrentOrientation()
{
var orientation = UIDevice.CurrentDevice.Orientation;
return orientation switch
{
UIDeviceOrientation.Portrait or
UIDeviceOrientation.PortraitUpsideDown => DeviceOrientation.Portrait,
UIDeviceOrientation.LandscapeLeft or
UIDeviceOrientation.LandscapeRight => DeviceOrientation.Landscape,
_ => DeviceOrientation.Unknown,
};
}
}
Benadering 3: dependency injection met platformspecifieke implementaties
Dit is naar mijn mening de meest schaalbare en testbare aanpak — zeker als je project wat groter wordt. Je definieert een interface in de gedeelde code en registreert platformspecifieke implementaties via DI:
// Interfaces/IHapticFeedbackService.cs (gedeeld)
namespace MijnApp.Interfaces;
public interface IHapticFeedbackService
{
void LichteTik();
void MediumTik();
void ZwareTik();
void Trillen(int duurInMs);
}
// Platforms/Android/Services/HapticFeedbackService.cs
using Android.OS;
using MijnApp.Interfaces;
namespace MijnApp.Platforms.Android.Services;
public class HapticFeedbackService : IHapticFeedbackService
{
public void LichteTik()
{
var vibrator = GetVibrator();
if (Build.VERSION.SdkInt >= BuildVersionCodes.Q)
{
vibrator?.Vibrate(
VibrationEffect.CreatePredefined(VibrationEffect.EffectTick));
}
}
public void MediumTik()
{
var vibrator = GetVibrator();
if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
{
vibrator?.Vibrate(
VibrationEffect.CreateOneShot(50, VibrationEffect.DefaultAmplitude));
}
}
public void ZwareTik()
{
var vibrator = GetVibrator();
if (Build.VERSION.SdkInt >= BuildVersionCodes.Q)
{
vibrator?.Vibrate(
VibrationEffect.CreatePredefined(VibrationEffect.EffectHeavyClick));
}
}
public void Trillen(int duurInMs)
{
var vibrator = GetVibrator();
if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
{
vibrator?.Vibrate(
VibrationEffect.CreateOneShot(duurInMs,
VibrationEffect.DefaultAmplitude));
}
}
private Vibrator? GetVibrator()
{
var context = global::Android.App.Application.Context;
return (Vibrator?)context.GetSystemService(
global::Android.Content.Context.VibratorService);
}
}
// Platforms/iOS/Services/HapticFeedbackService.cs
using MijnApp.Interfaces;
using UIKit;
namespace MijnApp.Platforms.iOS.Services;
public class HapticFeedbackService : IHapticFeedbackService
{
public void LichteTik()
{
var generator = new UIImpactFeedbackGenerator(
UIImpactFeedbackStyle.Light);
generator.Prepare();
generator.ImpactOccurred();
}
public void MediumTik()
{
var generator = new UIImpactFeedbackGenerator(
UIImpactFeedbackStyle.Medium);
generator.Prepare();
generator.ImpactOccurred();
}
public void ZwareTik()
{
var generator = new UIImpactFeedbackGenerator(
UIImpactFeedbackStyle.Heavy);
generator.Prepare();
generator.ImpactOccurred();
}
public void Trillen(int duurInMs)
{
// iOS ondersteunt geen vrije trilduur,
// dus we gebruiken notificatie-feedback als alternatief
var generator = new UINotificationFeedbackGenerator();
generator.Prepare();
generator.NotificationOccurred(UINotificationFeedbackType.Warning);
}
}
De registratie doe je in MauiProgram.cs:
// MauiProgram.cs
builder.Services.AddSingleton<IHapticFeedbackService>(
#if ANDROID
new MijnApp.Platforms.Android.Services.HapticFeedbackService()
#elif IOS
new MijnApp.Platforms.iOS.Services.HapticFeedbackService()
#else
// Fallback voor niet-ondersteunde platformen
null!
#endif
);
En vervolgens gebruik je de service overal via constructor-injectie. Lekker clean:
public partial class ProductDetailViewModel : ObservableObject
{
private readonly IHapticFeedbackService _hapticService;
public ProductDetailViewModel(IHapticFeedbackService hapticService)
{
_hapticService = hapticService;
}
[RelayCommand]
private void VoegToeAanFavorieten()
{
_hapticService.MediumTik();
// Logica voor favoriet toevoegen...
}
}
Toegang tot native platform-API's
.NET MAUI biedt een uitgebreide set ingebouwde API's voor veelgebruikte platformfuncties. Deze abstraheren de platformspecifieke details en geven je een uniforme interface. Hier zijn een paar voorbeelden die je in de praktijk vrijwel altijd nodig hebt.
Geolocatie met IGeolocation
Het opvragen van de locatie van een gebruiker is een van de meest voorkomende requirements. Gelukkig maakt .NET MAUI dit vrij eenvoudig via de IGeolocation-interface:
// Services/LocatieService.cs
using MijnApp.Models;
namespace MijnApp.Services;
public class LocatieService
{
private readonly IGeolocation _geolocation;
private CancellationTokenSource? _annuleerToken;
public LocatieService(IGeolocation geolocation)
{
_geolocation = geolocation;
}
public async Task<LocatieResultaat> HaalHuidigeLocatieOpAsync()
{
try
{
// Controleer eerst of locatie-services zijn ingeschakeld
var status = await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
{
status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
{
return new LocatieResultaat
{
IsSuccesvol = false,
Foutmelding = "Locatietoegang is geweigerd door de gebruiker."
};
}
}
_annuleerToken = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var verzoek = new GeolocationRequest(
GeolocationAccuracy.High, TimeSpan.FromSeconds(10));
var locatie = await _geolocation.GetLocationAsync(
verzoek, _annuleerToken.Token);
if (locatie != null)
{
return new LocatieResultaat
{
IsSuccesvol = true,
Breedtegraad = locatie.Latitude,
Lengtegraad = locatie.Longitude,
Nauwkeurigheid = locatie.Accuracy ?? 0,
Tijdstempel = locatie.Timestamp
};
}
return new LocatieResultaat
{
IsSuccesvol = false,
Foutmelding = "Kon de locatie niet bepalen."
};
}
catch (FeatureNotSupportedException)
{
return new LocatieResultaat
{
IsSuccesvol = false,
Foutmelding = "Geolocatie wordt niet ondersteund op dit apparaat."
};
}
catch (OperationCanceledException)
{
return new LocatieResultaat
{
IsSuccesvol = false,
Foutmelding = "Het locatieverzoek is verlopen."
};
}
}
public void AnnuleerVerzoek()
{
_annuleerToken?.Cancel();
_annuleerToken?.Dispose();
_annuleerToken = null;
}
}
// Models/LocatieResultaat.cs
namespace MijnApp.Models;
public class LocatieResultaat
{
public bool IsSuccesvol { get; set; }
public double Breedtegraad { get; set; }
public double Lengtegraad { get; set; }
public double Nauwkeurigheid { get; set; }
public DateTimeOffset Tijdstempel { get; set; }
public string? Foutmelding { get; set; }
}
Camera met MediaPicker
Foto's maken of afbeeldingen uit de galerij selecteren? Dat gaat vrij recht-toe-recht-aan met de MediaPicker-API:
public class FotoService
{
private readonly IMediaPicker _mediaPicker;
public FotoService(IMediaPicker mediaPicker)
{
_mediaPicker = mediaPicker;
}
public async Task<string?> MaakFotoAsync()
{
if (!_mediaPicker.IsCaptureSupported)
{
await Shell.Current.DisplayAlert(
"Niet ondersteund",
"Camera is niet beschikbaar op dit apparaat.",
"OK");
return null;
}
try
{
var foto = await _mediaPicker.CapturePhotoAsync(
new MediaPickerOptions
{
Title = "Maak een foto"
});
if (foto == null) return null;
// Sla de foto op in de lokale app-opslag
var lokaalPad = Path.Combine(
FileSystem.AppDataDirectory,
$"foto_{DateTime.Now:yyyyMMdd_HHmmss}.jpg");
using var bronStream = await foto.OpenReadAsync();
using var doelStream = File.OpenWrite(lokaalPad);
await bronStream.CopyToAsync(doelStream);
return lokaalPad;
}
catch (PermissionException)
{
await Shell.Current.DisplayAlert(
"Toestemming vereist",
"Cameratoegang is nodig om foto's te maken.",
"OK");
return null;
}
}
public async Task<string?> KiesFotoUitGalerijAsync()
{
try
{
var foto = await _mediaPicker.PickPhotoAsync(
new MediaPickerOptions
{
Title = "Kies een foto"
});
if (foto == null) return null;
var lokaalPad = Path.Combine(
FileSystem.AppDataDirectory,
$"gekozen_{DateTime.Now:yyyyMMdd_HHmmss}.jpg");
using var bronStream = await foto.OpenReadAsync();
using var doelStream = File.OpenWrite(lokaalPad);
await bronStream.CopyToAsync(doelStream);
return lokaalPad;
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert(
"Fout",
$"Kon de foto niet laden: {ex.Message}",
"OK");
return null;
}
}
}
Biometrische authenticatie
Voor vingerafdruk- en gezichtsherkenning moet je platformspecifieke API's aanspreken. Hier is een implementatie die de dependency injection-benadering gebruikt (en in mijn ervaring is dit de manier waarop je dit in productie-apps wilt doen):
// Interfaces/IBiometrischService.cs
namespace MijnApp.Interfaces;
public interface IBiometrischService
{
Task<bool> IsBiometrischBeschikbaarAsync();
Task<BiometrischResultaat> AuthenticeerAsync(string reden);
}
public record BiometrischResultaat(bool IsSuccesvol, string? Foutmelding = null);
// Platforms/Android/Services/BiometrischService.cs
using AndroidX.Biometric;
using AndroidX.Core.Content;
using AndroidX.Fragment.App;
using Java.Util.Concurrent;
using MijnApp.Interfaces;
namespace MijnApp.Platforms.Android.Services;
public class BiometrischService : IBiometrischService
{
public Task<bool> IsBiometrischBeschikbaarAsync()
{
var context = global::Android.App.Application.Context;
var manager = BiometricManager.From(context);
var resultaat = manager.CanAuthenticate(
BiometricManager.Authenticators.BiometricStrong);
return Task.FromResult(
resultaat == BiometricManager.BiometricSuccess);
}
public async Task<BiometrischResultaat> AuthenticeerAsync(string reden)
{
var tcs = new TaskCompletionSource<BiometrischResultaat>();
var activiteit = Platform.CurrentActivity as FragmentActivity;
if (activiteit == null)
{
return new BiometrischResultaat(false,
"Kon de huidige activiteit niet vinden.");
}
var executor = ContextCompat.GetMainExecutor(activiteit);
var callback = new BiometrischCallbackHandler(tcs);
var promptInfo = new BiometricPrompt.PromptInfo.Builder()
.SetTitle("Biometrische verificatie")
.SetSubtitle(reden)
.SetNegativeButtonText("Annuleren")
.SetAllowedAuthenticators(
BiometricManager.Authenticators.BiometricStrong)
.Build();
var biometricPrompt = new BiometricPrompt(
activiteit, executor, callback);
MainThread.BeginInvokeOnMainThread(() =>
{
biometricPrompt.Authenticate(promptInfo);
});
return await tcs.Task;
}
private class BiometrischCallbackHandler : BiometricPrompt.AuthenticationCallback
{
private readonly TaskCompletionSource<BiometrischResultaat> _tcs;
public BiometrischCallbackHandler(
TaskCompletionSource<BiometrischResultaat> tcs) => _tcs = tcs;
public override void OnAuthenticationSucceeded(
BiometricPrompt.AuthenticationResult result)
{
_tcs.TrySetResult(new BiometrischResultaat(true));
}
public override void OnAuthenticationFailed()
{
// Wordt aangeroepen bij een mislukte poging,
// maar de prompt blijft actief
}
public override void OnAuthenticationError(
int errorCode, Java.Lang.ICharSequence errString)
{
_tcs.TrySetResult(new BiometrischResultaat(false,
errString?.ToString() ?? "Onbekende fout"));
}
}
}
Platformspecifieke machtigingen
.NET MAUI biedt een geünificeerd machtigingssysteem, en dat is echt een verademing vergeleken met hoe dit vroeger ging. Naast de standaard machtigingen kun je ook aangepaste machtigingen definiëren:
// Helpers/MachtigingenHelper.cs
namespace MijnApp.Helpers;
public static class MachtigingenHelper
{
public static async Task<bool> ControleerEnVraagMachtigingAsync<T>()
where T : Permissions.BasePermission, new()
{
var status = await Permissions.CheckStatusAsync<T>();
if (status == PermissionStatus.Granted)
return true;
if (status == PermissionStatus.Denied
&& DeviceInfo.Platform == DevicePlatform.iOS)
{
// Op iOS kan een geweigerde machtiging niet opnieuw
// worden aangevraagd — verwijs naar Instellingen
await Shell.Current.DisplayAlert(
"Machtiging vereist",
"Ga naar Instellingen om deze machtiging in te schakelen.",
"OK");
return false;
}
if (Permissions.ShouldShowRationale<T>())
{
await Shell.Current.DisplayAlert(
"Machtiging nodig",
"Deze functie heeft aanvullende machtigingen nodig " +
"om correct te werken.",
"Begrepen");
}
status = await Permissions.RequestAsync<T>();
return status == PermissionStatus.Granted;
}
public static async Task<bool> ControleerCameraMachtigingAsync()
{
return await ControleerEnVraagMachtigingAsync<Permissions.Camera>();
}
public static async Task<bool> ControleerLocatieMachtigingAsync()
{
return await ControleerEnVraagMachtigingAsync
<Permissions.LocationWhenInUse>();
}
public static async Task<bool> ControleerOpslagMachtigingAsync()
{
return await ControleerEnVraagMachtigingAsync
<Permissions.StorageRead>();
}
}
Vergeet niet om de benodigde machtigingen in je Android-manifest en iOS Info.plist te configureren. Dit is een stap die makkelijk over het hoofd wordt gezien, maar zonder deze configuratie werkt je machtigingsaanvraag simpelweg niet:
<!-- Platforms/Android/AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Platforms/iOS/Info.plist (relevante sleutels) -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Deze app heeft uw locatie nodig om nabijgelegen winkels te tonen.</string>
<key>NSCameraUsageDescription</key>
<string>Deze app gebruikt de camera voor het scannen van barcodes.</string>
<key>NSFaceIDUsageDescription</key>
<string>Deze app gebruikt Face ID voor veilige authenticatie.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Deze app heeft toegang tot uw fotobibliotheek nodig.</string>
Multi-targeting configureren
Een correct geconfigureerd projectbestand is essentieel — zonder de juiste multi-targeting setup compileert je platformspecifieke code simpelweg niet. .NET MAUI maakt gebruik van het multi-targeting systeem van .NET om vanuit één project meerdere platformen te ondersteunen.
Het .csproj-bestand configureren
Het standaard .NET MAUI-projectbestand bevat al de basis, maar hier is een uitgebreider voorbeeld met veelgebruikte aanpassingen die je in de praktijk nodig zult hebben:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst</TargetFrameworks>
<!-- Uncomment om Windows te ondersteunen -->
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-windows10.0.19041.0</TargetFrameworks> -->
<OutputType>Exe</OutputType>
<RootNamespace>MijnApp</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- App-informatie -->
<ApplicationTitle>MijnApp</ApplicationTitle>
<ApplicationId>nl.mijnbedrijf.mijnapp</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
</PropertyGroup>
<!-- Android-specifieke instellingen -->
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<DefineConstants>$(DefineConstants);ANDROID</DefineConstants>
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
</PropertyGroup>
<!-- iOS-specifieke instellingen -->
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">
<DefineConstants>$(DefineConstants);IOS</DefineConstants>
</PropertyGroup>
<!-- Platform-specifieke NuGet-pakketten -->
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<PackageReference Include="Xamarin.AndroidX.Biometric" Version="1.2.0.1" />
<PackageReference Include="Xamarin.Google.Android.Material" Version="1.11.0.2" />
</ItemGroup>
<!-- Gedeelde NuGet-pakketten -->
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="CommunityToolkit.Maui" Version="7.0.1" />
</ItemGroup>
</Project>
De mappenstructuur
Een goed georganiseerde mappenstructuur maakt of breekt de onderhoudbaarheid van je project. Hier is de structuur die ik aanraad:
MijnApp/
├── Controls/
│ └── RatingControl.cs // Cross-platform control
├── Handlers/
│ └── RatingControlHandler.cs // Gedeelde handler-definitie
├── Interfaces/
│ ├── IBiometrischService.cs
│ └── IHapticFeedbackService.cs
├── Models/
│ └── LocatieResultaat.cs
├── Services/
│ ├── LocatieService.cs // Gedeelde service
│ └── DeviceOrientationService.cs // Partiële klasse (gedeeld deel)
├── ViewModels/
│ └── ProductDetailViewModel.cs
├── Views/
│ └── ProductDetailPage.xaml
├── Platforms/
│ ├── Android/
│ │ ├── Handlers/
│ │ │ └── RatingControlHandler.cs // Android-implementatie
│ │ ├── Services/
│ │ │ ├── BiometrischService.cs
│ │ │ ├── HapticFeedbackService.cs
│ │ │ └── DeviceOrientationService.cs
│ │ ├── AndroidManifest.xml
│ │ ├── MainActivity.cs
│ │ └── MainApplication.cs
│ ├── iOS/
│ │ ├── Handlers/
│ │ │ └── RatingControlHandler.cs // iOS-implementatie
│ │ ├── Services/
│ │ │ ├── BiometrischService.cs
│ │ │ ├── HapticFeedbackService.cs
│ │ │ └── DeviceOrientationService.cs
│ │ ├── Info.plist
│ │ ├── AppDelegate.cs
│ │ └── Program.cs
│ └── MacCatalyst/
│ └── ...
├── Resources/
│ ├── Images/
│ ├── Fonts/
│ └── Styles/
├── App.xaml
├── App.xaml.cs
├── MauiProgram.cs
└── MijnApp.csproj
Belangrijk om te weten: bestanden in de Platforms-submappen worden automatisch conditioneel gecompileerd op basis van het doelplatform. Code in Platforms/Android/ wordt dus alleen meegenomen bij het compileren voor Android. Deze conventie zit standaard in de .NET MAUI-projectsjabloon en bespaart je het handmatig beheren van conditionele compilatie op bestandsniveau. Handig!
Best practices en veelgemaakte fouten
Nu we de technische details hebben doorgenomen, is het tijd voor het deel dat je misschien wel het meeste tijd gaat besparen: de valkuilen waar je tegenaan kunt lopen (en hoe je ze voorkomt).
Best practices voor handler-ontwikkeling
- Gebruik altijd ConnectHandler en DisconnectHandler — Registreer event-handlers in
ConnectHandleren verwijder ze inDisconnectHandler. Dit voorkomt geheugenlekken. Serieus, dit is een van de meest voorkomende oorzaken van problemen die ik in de praktijk tegenkom. - Houd mapping-methoden statisch — De
PropertyMapper- enCommandMapper-methoden moetenstaticzijn. Niet alleen conventie, maar ook een prestatievereiste. - Controleer altijd op null — Zowel
VirtualViewalsPlatformViewkunnennullzijn in bepaalde levenscyclusfasen. Het klinkt vanzelfsprekend, maar het wordt nog vaak vergeten. - Gebruik AppendToMapping in plaats van ModifyMapping waar mogelijk —
AppendToMappingvoegt toe aan bestaand gedrag en is minder invasief. Bij toekomstige framework-updates loop je dan minder risico op problemen.
// Goed: ConnectHandler en DisconnectHandler correct gebruiken
public partial class MijnHandler : ViewHandler<IMijnControl, PlatformView>
{
protected override void ConnectHandler(PlatformView platformView)
{
base.ConnectHandler(platformView);
// Registreer event-handlers hier
platformView.Click += OnPlatformViewClicked;
platformView.LongClick += OnPlatformViewLongClicked;
}
protected override void DisconnectHandler(PlatformView platformView)
{
// Verwijder ALTIJD event-handlers om geheugenlekken te voorkomen
platformView.Click -= OnPlatformViewClicked;
platformView.LongClick -= OnPlatformViewLongClicked;
base.DisconnectHandler(platformView);
}
private void OnPlatformViewClicked(object? sender, EventArgs e)
{
// Controleer altijd op null
if (VirtualView is null) return;
VirtualView.KlikCommando?.Execute(null);
}
private void OnPlatformViewLongClicked(object? sender,
Android.Views.View.LongClickEventArgs e)
{
if (VirtualView is null) return;
VirtualView.LangDrukkenCommando?.Execute(null);
}
}
Prestatie-overwegingen
- Minimaliseer property-updates — Elke eigendomswijziging triggert de mapper. Batch wijzigingen waar mogelijk om onnodige native updates te voorkomen.
- Vermijd zware operaties in mappers — Mapping-methoden draaien op de UI-thread. Geen langdurige berekeningen of I/O hier, alsjeblieft.
- Hergebruik native objecten — Het aanmaken van native objecten is kostbaar (vooral op Android). Hergebruik drawables, kleuren en andere native objecten.
- Gebruik de juiste levenscyclusmethoden — Initialiseer zware resources in
ConnectHandleren maak ze vrij inDisconnectHandler, niet in de constructor.
Veelgemaakte fouten
Hier zijn de valkuilen waar ik ontwikkelaars het vaakst in zie trappen:
- Vergeten de handler te registreren — Klinkt simpel, maar het overkomt iedereen minstens één keer. Zonder registratie in
MauiProgram.csweet .NET MAUI niet welke handler bij je control hoort, en krijg je een lege plek op het scherm. - UI-thread schendingen — Native UI-bewerkingen moeten op de hoofdthread draaien. Gebruik
MainThread.BeginInvokeOnMainThreadwanneer je native weergaven bijwerkt vanuit een achtergrondthread:// Fout: Direct native weergave bijwerken vanuit achtergrondthread Task.Run(() => { PlatformView.Text = "Bijgewerkt"; // Crash! }); // Goed: Gebruik MainThread Task.Run(async () => { var resultaat = await BerekenWaardeAsync(); MainThread.BeginInvokeOnMainThread(() => { if (PlatformView is not null) PlatformView.Text = resultaat; }); }); - Geheugenlekken door niet-verwijderde event-handlers — Registreer je een event-handler in
ConnectHandlermaar vergeet je de verwijdering inDisconnectHandler? Dan houdt het native control een referentie vast en kan de garbage collector het geheugen niet vrijgeven. Dit soort lekken zijn lastig te debuggen. - Platform-specifieke types in gedeelde code — Gebruik geen platform-specifieke types direct in gedeelde code. Dat geeft compilatiefouten op andere platformen:
// Fout: Dit compileert niet op iOS
public void DoeIets()
{
var context = Android.App.Application.Context; // Compilatiefout op iOS!
}
// Goed: Gebruik conditionele compilatie
public void DoeIets()
{
#if ANDROID
var context = Android.App.Application.Context;
// Android-specifieke code
#elif IOS
// iOS-specifieke code
#endif
}
// Nog beter: Gebruik dependency injection
public class MijnService
{
private readonly IPlatformService _platformService;
public MijnService(IPlatformService platformService)
{
_platformService = platformService;
}
public void DoeIets()
{
_platformService.VoerPlatformActieUit();
}
}
Testen van handlers
Het testen van handlers is eerlijk gezegd niet het eenvoudigste onderdeel van .NET MAUI-ontwikkeling, vanwege de platformafhankelijkheid. Maar er zijn goede strategieën:
- Test de cross-platform logica apart — Door je controls te ontwerpen met duidelijke scheiding van verantwoordelijkheden, kun je de logica onafhankelijk van de handler testen.
- Gebruik interface-abstracties — De DI-benadering maakt het makkelijk om platformservices te mocken in unit tests.
- Gebruik Appium of Xamarin.UITest voor UI-tests — Voor het testen van de daadwerkelijke native weergave op het apparaat zijn geautomatiseerde UI-testframeworks onmisbaar.
// Voorbeeld: Unit test voor het RatingControl
[TestClass]
public class RatingControlTests
{
[TestMethod]
public void Rating_WhenSet_ShouldClampToMaxRating()
{
var control = new RatingControl
{
MaxRating = 5
};
control.Rating = 3;
Assert.AreEqual(3, control.Rating);
}
[TestMethod]
public void RatingChanged_WhenRatingChanges_ShouldFireEvent()
{
var control = new RatingControl();
int? ontvangen = null;
control.RatingChanged += (sender, args) =>
{
ontvangen = args.NewRating;
};
control.Rating = 4;
Assert.AreEqual(4, ontvangen);
}
[TestMethod]
public void Rating_Default_ShouldBeZero()
{
var control = new RatingControl();
Assert.AreEqual(0, control.Rating);
}
}
Conclusie
De handler-architectuur in .NET MAUI is een flinke stap vooruit ten opzichte van het renderer-patroon uit Xamarin.Forms. De losjes gekoppelde opzet, gecombineerd met PropertyMapper, CommandMapper en partiële klassen, geeft je een krachtig en flexibel systeem om cross-platform apps te bouwen die toch volledig toegang hebben tot native platformmogelijkheden.
Wat hebben we behandeld?
- Hoe de handler-architectuur werkt en hoe virtuele weergaven via PropertyMapper en CommandMapper aan native controls worden gekoppeld.
- Het aanpassen van bestaande controls met
AppendToMapping,PrependToMappingenModifyMapping— zonder een volledige custom handler te schrijven. - Het bouwen van een volledig custom control (het RatingControl) met platform-specifieke implementaties voor Android en iOS.
- Drie benaderingen voor platformspecifieke code: conditionele compilatie, partiële klassen en dependency injection.
- Toegang tot native API's zoals geolocatie, camera en biometrische authenticatie.
- Multi-targeting configuratie en de aanbevolen mappenstructuur.
- Best practices en de meest voorkomende valkuilen.
De sleutel is het vinden van de juiste balans. Gebruik de ingebouwde .NET MAUI-API's waar mogelijk, pas bestaande handlers aan via de mapping-methoden voor eenvoudige wijzigingen, en bouw alleen een volledige custom handler wanneer dat echt nodig is. Begin met AppendToMapping voor kleine aanpassingen en werk je geleidelijk op naar volledig aangepaste controls. Met die aanpak bouw je apps die op elk platform native aanvoelen, terwijl je het overgrote deel van je code maar één keer schrijft.