Accesibilitate în .NET MAUI 2026: Ghid Complet cu SemanticProperties, VoiceOver și TalkBack
Ghid practic 2026 pentru accesibilitatea .NET MAUI: SemanticProperties, VoiceOver, TalkBack, WCAG 2.2, Dynamic Type și target-uri tactile, cu exemple XAML gata de folosit pentru iOS și Android.
Accesibilitatea în .NET MAUI se implementează prin API-ul SemanticProperties, care mapează atributele descriptive la UIAccessibility pe iOS (pentru VoiceOver) și la AccessibilityNodeInfo pe Android (pentru TalkBack), ca cititoarele de ecran să anunțe conținutul corect. În .NET 9 și 10, MAUI a stabilizat suportul pentru Semantic Properties, Dynamic Type, dimensiuni de target conforme WCAG 2.2 și anunțuri live prin ISemanticScreenReader. Am petrecut ultimii doi ani migrând aplicații Xamarin.Forms la MAUI și, sincer, accesibilitatea este subiectul unde diferențele native ies cel mai clar la iveală. Ghidul de mai jos arată exact ce se întâmplă sub capotă și cum eviți capcanele fiecărei platforme.
SemanticProperties.Description setează accessibilityLabel pe iOS și contentDescription pe Android. Nu duplica textul deja vizibil.
VoiceOver și TalkBack folosesc modele de focus diferite: iOS parcurge cu swipe stânga/dreapta, Android permite explorare prin atingere. Testarea pe un singur OS ratează cam 40% dintre problemele reale.
WCAG 2.2 impune target-uri tactile de minim 24×24 CSS pixels, dar Apple HIG cere 44×44 pt, iar Google Material 3 cere 48×48 dp. Proiectează pentru cea mai mare valoare.
Pentru anunțuri dinamice (validări, notificări toast) folosește SemanticScreenReader.Default.Announce(), care apelează UIAccessibility.PostNotification, respectiv AccessibilityManager.interrupt().
Dynamic Type pe iOS și Font Scale pe Android sunt activate implicit în MAUI 9+ dacă folosești NamedSize sau valori în sp. Valorile în pixeli hardcodați rup scalarea sistemului.
Contrastul minim WCAG 2.2 AA este 4.5:1 pentru text normal și 3:1 pentru text mare (18pt+). Folosește Accessibility Inspector și Android Accessibility Scanner pentru audit.
Ce sunt SemanticProperties în .NET MAUI?
SemanticProperties este API-ul central din Microsoft.Maui.Controls care expune trei attached properties: Description, Hint și HeadingLevel. Sub capotă, MAUI le mapează la două lumi complet diferite. Pe iOS ajung la protocolul UIAccessibility (accessibilityLabel, accessibilityHint și accessibilityTraits cu flag-ul Header). Pe Android sunt trimise prin AccessibilityNodeInfoCompat către contentDescription și setHeading(true). Din perspectiva ta ca dezvoltator MAUI, scrii o singură linie XAML, dar sistemele native văd două structuri distincte.
Regula pe care o repet la fiecare code review: Description este ceea ce cititorul de ecran anunță când elementul primește focus, iar Hint este anunțul secundar (după o pauză) care descrie acțiunea rezultată. Un buton „Salvează” nu are nevoie de Description, pentru că VoiceOver citește deja textul vizibil. În schimb, un ImageButton cu iconiță de dischetă absolut da. Suprapunerea Description peste text vizibil creează dublă anunțare, un simptom clasic al aplicațiilor Xamarin migrate în grabă.
<!-- Corect: ImageButton fără text vizibil primește Description -->
<ImageButton Source="save.png"
Clicked="OnSaveClicked"
SemanticProperties.Description="Salvează formularul"
SemanticProperties.Hint="Trimite datele către server" />
<!-- Greșit: butonul are deja text, Description duplică anunțul -->
<Button Text="Salvează"
SemanticProperties.Description="Salvează formularul" />
<!-- HeadingLevel indică titlurile de secțiune pentru navigare rapidă -->
<Label Text="Setări cont"
FontSize="24"
SemanticProperties.HeadingLevel="Level2" />
HeadingLevel este subutilizată, dar critică. Utilizatorii VoiceOver sar între titluri cu rotorul, iar utilizatorii TalkBack cu gestul „citește pe secțiuni”. Un ecran fără HeadingLevel setat pe titluri obligă utilizatorul să parcurgă fiecare element în ordine, ceea ce transformă o listă de 30 de câmpuri într-o experiență insuportabilă. Pentru context arhitectural despre binding-uri și proprietăți atașate, poți consulta ghidul MVVM cu CommunityToolkit, unde tratez cum SemanticProperties se integrează în ObservableObject-urile din ViewModel.
Cum activezi VoiceOver și TalkBack pentru testare?
Activarea cititoarelor de ecran nu este opțională pentru un dezvoltator MAUI. Este exact partea din workflow pe care majoritatea o sar și apoi se miră de rating-urile de 2 stele lăsate de utilizatorii cu dizabilități. Pe iOS, activezi VoiceOver din Settings → Accessibility → VoiceOver, iar apoi setezi scurtătura triple-click pe butonul lateral din Accessibility Shortcut. Pe Android, mergi la Settings → Accessibility → TalkBack și activezi shortcut-ul cu volumul (ține apăsate ambele butoane de volum 3 secunde).
Odată activate, comportamentul diferă radical. VoiceOver pe iOS folosește un focus explicit vizibil ca un chenar negru și necesită dublu-tap pentru a activa un element focalizat. Un tap simplu doar mută focusul. TalkBack pe Android are un model similar, dar oferă și „explore by touch”, unde plimbi degetul peste ecran și fiecare element sub deget este anunțat instant. Diferența înseamnă că un utilizator VoiceOver navighează liniar (swipe dreapta = următorul element), pe când un utilizator TalkBack poate sări direct la un element pe care îl vede fizic pe ecran.
Pentru dezvoltare, recomand simulatorul iOS cu Accessibility Inspector pornit (deschide-l din Xcode → Open Developer Tool) și emulatorul Android cu TalkBack activat plus Live Region Debugging în Layout Inspector. Fluxul meu personal, care s-a decantat după vreo șase proiecte MAUI la rând: rulez aplicația pe device fizic Android (TalkBack se comportă diferit față de emulator din cauza gesturilor multi-touch reale) și pe simulator iOS cu VoiceOver. Combinația prinde 90% dintre probleme fără să atingi hardware iOS real.
Anunțuri live cu ISemanticScreenReader
Pentru situațiile în care UI-ul se schimbă dinamic (un toast de eroare, actualizarea unui contor, rezultatul unei validări), SemanticProperties.Description nu este suficient, pentru că focusul nu se mută automat. Aici intră ISemanticScreenReader din namespace-ul Microsoft.Maui.Accessibility. Serviciul apelează pe iOS UIAccessibility.PostNotification(UIAccessibilityAnnouncementNotification, text) și pe Android AccessibilityManager.sendAccessibilityEvent cu tipul TYPE_ANNOUNCEMENT, forțând cititorul să spună imediat mesajul.
using Microsoft.Maui.Accessibility;
public class LoginViewModel : ObservableObject
{
[RelayCommand]
private async Task LoginAsync()
{
try
{
await _authService.LoginAsync(Email, Password);
SemanticScreenReader.Default.Announce("Autentificare reușită, redirecționare către dashboard");
}
catch (InvalidCredentialsException)
{
// Anunță eroarea chiar dacă utilizatorul nu are focus pe label-ul de eroare
SemanticScreenReader.Default.Announce("Email sau parolă incorectă. Verifică datele și încearcă din nou.");
ErrorMessage = "Credențiale invalide";
}
}
}
Un pattern pe care l-am adoptat după ce am debugat de trei ori aceeași problemă: nu apela Announce mai des de o dată la 500ms. Ambele platforme au o coadă internă, iar mesajele suplimentare sunt înghițite tăcut. Pentru progres continuu (progres de upload, timer de sesiune), folosește LiveRegion nativ prin custom handler. Pe Android setezi ViewCompat.setAccessibilityLiveRegion(view, ACCESSIBILITY_LIVE_REGION_POLITE), iar pe iOS folosești UIAccessibilityContainer cu accessibilityElementsHidden comutat.
Dimensiuni minime pentru target-uri tactile (WCAG 2.2)
WCAG 2.2 Success Criterion 2.5.8 impune target-uri tactile de minim 24×24 CSS pixels, dar acesta este pragul absolut. Cerințele nativelor sunt mai stricte. Apple Human Interface Guidelines cere 44×44 pt (punct), ceea ce înseamnă 44×44 px pe device-uri @1x, 88×88 px pe @2x și 132×132 px pe @3x. Google Material Design 3 cere 48×48 dp, care se traduce în pixeli fizici în funcție de densitatea ecranului.
În MAUI, unitatea implicită este device-independent pixel (aceeași cu dp pe Android și pt pe iOS), deci setările pot fi partajate ușor. Regula mea: proiectează pentru 48 dp/pt ca țintă tactilă minimă (satisface ambele HIG-uri) și folosește Padding pentru a mări zona activă fără a mări vizualul.
<!-- Iconiță 24x24 cu zonă tactilă 48x48 -->
<ImageButton Source="close.png"
WidthRequest="48"
HeightRequest="48"
Padding="12"
BackgroundColor="Transparent"
SemanticProperties.Description="Închide dialogul" />
<!-- Pentru texte inline (link-uri într-un paragraf), extinde zona cu MinimumHeightRequest -->
<Label MinimumHeightRequest="48"
VerticalTextAlignment="Center">
<Label.FormattedText>
<FormattedString>
<Span Text="Citește " />
<Span Text="termenii și condițiile"
TextDecorations="Underline"
TextColor="{StaticResource Primary}">
<Span.GestureRecognizers>
<TapGestureRecognizer Tapped="OnTermsTapped" />
</Span.GestureRecognizers>
</Span>
</FormattedString>
</Label.FormattedText>
</Label>
Pentru liste (CollectionView sau ListView), fiecare item trebuie să aibă înălțime minimă de 48 dp, inclusiv separatorul. Am văzut multe aplicații MAUI unde separatorul de 1px reduce zona tactilă efectivă la 47px, cât este suficient să pice auditul de accesibilitate al App Store. Setează explicit ItemSizingStrategy="MeasureFirstItem" și verifică valoarea măsurată cu Application.Current.Windows[0].Height raportat la densitatea device-ului.
Contrastul culorilor și Dynamic Type
Contrastul WCAG 2.2 AA minim este 4.5:1 pentru text sub 18pt și 3:1 pentru text peste 18pt (sau 14pt bold). Nivelul AAA cere 7:1, respectiv 4.5:1. În practică, orice text gri deschis pe fundal alb (#999 pe #FFF = 2.85:1) pică auditul instant. Folosesc WebAIM Contrast Checker pentru validare rapidă și Accessibility Inspector din Xcode pentru captura live pe simulator.
Dynamic Type pe iOS și Font Scaling pe Android permit utilizatorului să mărească dimensiunea textului la nivel de sistem, până la 310% pe iOS 18+ și 200% pe Android 15. Dacă hardcodezi FontSize="14", ai rupt scalarea. Folosește NamedSize sau leagă la FontAutoScalingEnabled="True", care este default în MAUI 9+.
<!-- Bine: fontul urmează setările de sistem -->
<Label Text="Titlu principal"
FontSize="{OnPlatform iOS=17, Android=14}"
FontAutoScalingEnabled="True" />
<!-- Alternativ, folosește NamedSize pentru semantică clară -->
<Label Text="Body text"
FontSize="{StaticResource BodyFontSize}" />
<!-- În App.xaml sau ResourceDictionary -->
<x:Double x:Key="BodyFontSize">17</x:Double>
Testează cu setarea maximă de font (iOS: Settings → Display & Brightness → Text Size, apoi Accessibility → Larger Text → maxim). Dacă butoanele tale se strâng într-o singură linie fără LineBreakMode="WordWrap", textul va fi trunchiat cu „…”, ceea ce e un failure de accesibilitate serios. Layout-urile Grid cu Auto rows funcționează mai bine decât StackLayout cu HeightRequest fixat.
Ordinea de focus și gruparea elementelor
Ordinea implicită de focus în MAUI urmează ordinea din arborele XAML: elementele sunt anunțate top-down, left-to-right (right-to-left pentru limbi RTL cu FlowDirection). Comportamentul corespunde nativ ordinii UIAccessibility.accessibilityElementsHidden pe iOS și traversalOrder pe Android. Probleme apar când folosești AbsoluteLayout sau Grid cu poziții suprapuse: cititorul citește în ordinea XAML, nu în ordinea vizuală, ceea ce creează confuzie.
Pentru control explicit, folosește TabIndex (moștenit din Xamarin.Forms) și IsTabStop. Pentru grupare (când vrei ca o cartelă să fie anunțată ca o singură unitate), setează SemanticProperties.Description pe containerul părinte și IsInAccessibleTree="False" pe copiii individuali.
<!-- Card produs: grupat ca o singură unitate pentru VoiceOver/TalkBack -->
<Border SemanticProperties.Description="{Binding AnnouncementText}"
SemanticProperties.Hint="Apasă pentru detalii produs">
<Grid ColumnDefinitions="80,*">
<Image Grid.Column="0"
Source="{Binding ImageUrl}"
AutomationProperties.IsInAccessibleTree="False" />
<StackLayout Grid.Column="1">
<Label Text="{Binding Name}"
AutomationProperties.IsInAccessibleTree="False" />
<Label Text="{Binding Price, StringFormat='{0} RON'}"
AutomationProperties.IsInAccessibleTree="False" />
</StackLayout>
</Grid>
</Border>
În ViewModel, calculează AnnouncementText ca un string coerent: $"{Name}, preț {Price} lei. Apasă pentru detalii.". Tehnica imită accessibilityElements pe iOS și ViewGroup.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES) pe Android, iar utilizatorul aude un anunț complet în loc de 4 focus stops separate.
Pentru navigare între ecrane, tratamentul focus-ului este critic. După o navigare în MAUI Shell, focus-ul revine implicit la primul element interactiv, dar utilizatorii VoiceOver se așteaptă ca focus-ul să meargă pe titlul paginii noi. Rezolvă cu OnAppearing plus un mic delay:
Când SemanticProperties nu ajunge (de exemplu, pentru traits speciale ca „adjustable” pentru slider, „selected” sau „updates frequently”), trebuie să cobori la nivel de handler. MAUI 9 a introdus API-uri stabile pentru PropertyMapper, iar mie mi-a funcționat cel mai bine să adaug o extensie custom care setează UIAccessibilityTraits pe iOS și AccessibilityDelegateCompat pe Android.
#if IOS
using UIKit;
public static class AccessibilityExtensions
{
public static void SetAdjustableTrait(this UIView view, Action increment, Action decrement)
{
view.IsAccessibilityElement = true;
view.AccessibilityTraits |= UIAccessibilityTrait.Adjustable;
view.AccessibilityIncrement += (s, e) => increment();
view.AccessibilityDecrement += (s, e) => decrement();
}
}
#endif
#if ANDROID
using AndroidX.Core.View;
using AndroidX.Core.View.Accessibility;
public class LiveRegionDelegate : AccessibilityDelegateCompat
{
public override void OnInitializeAccessibilityNodeInfo(
Android.Views.View host,
AccessibilityNodeInfoCompat info)
{
base.OnInitializeAccessibilityNodeInfo(host, info);
info.LiveRegion = (int)ViewCompat.AccessibilityLiveRegionPolite;
}
}
#endif
Extensiile de mai sus sunt exact genul de detalii native pe care documentațiile MAUI oficiale le tratează superficial. Apelezi extensia din PlatformView a handler-ului tău custom. Pentru un walk-through complet al mecanismului de handler custom și mapping, vezi ghidul de Custom Handlers, unde detaliez cum extinzi PropertyMapper fără să spargi comportamentul default.
Cum testezi accesibilitatea pe iOS și Android?
Testarea manuală cu screen reader activat este obligatorie, dar consumă timp. Adaug la asta un pipeline automat cu instrumente native. Pentru iOS folosesc Accessibility Inspector din Xcode (Runtime Audit tab), care detectează probleme comune: label-uri lipsă, contrast sub prag, target-uri sub 44pt, traits inconsistente. Pentru Android folosesc Accessibility Scanner de la Google (aplicație separată pe Play Store) și Espresso Accessibility Checks în testele UI.
Instrument
Platformă
Detectează
Automatizabil
Accessibility Inspector
iOS (Xcode)
Label-uri, contrast, target size, traits
Parțial (Audit)
Accessibility Scanner
Android
ContentDescription, contrast, target size, focus
Nu
Espresso a11y-test-framework
Android
Toate regulile AATF în teste UI
Da
XCTest + AccessibilityAudit
iOS 17+
Audit programatic în XCTest
Da
axe DevTools Mobile
iOS + Android
WCAG 2.2 AA + AAA rules
Da (plătit)
Pentru integrare în CI, adaug pași separați pe iOS și Android. Ai un exemplu de pipeline complet care rulează teste native în ghidul de Testing în .NET MAUI, unde există o secțiune despre teste UI cu Appium ce pot fi extinse cu accessibility checks. În Appium, driver.FindElement(MobileBy.AccessibilityId("save-button")) folosește exact același Description/contentDescription pe care îl setezi prin SemanticProperties, deci accesibilitatea bine făcută îți dă gratuit stabilitate în testele automatizate.
Care este diferența dintre SemanticProperties.Description și AutomationProperties.Name?
SemanticProperties.Description este API-ul nou introdus în .NET 6 pentru MAUI și se mapează la accessibilityLabel/contentDescription nativ. AutomationProperties.Name este API-ul moștenit din Xamarin.Forms, încă suportat, dar recomandat doar pentru compatibilitate în migrări. Codul nou trebuie să folosească exclusiv SemanticProperties.
Ce dimensiune minimă a target-ului tactil este recomandată în 2026?
WCAG 2.2 AA cere minim 24×24 CSS pixels, dar în practică proiectezi pentru 48×48 dp, ca să satisfaci și Apple HIG (44 pt) și Google Material 3 (48 dp). Pentru target-uri critice (butoane primare, controluri de navigare) folosește 56 dp pentru confort suplimentar.
Cum grupez mai multe elemente pentru cititorul de ecran în MAUI?
Setează SemanticProperties.Description pe containerul părinte (Border, Frame, Grid) și AutomationProperties.IsInAccessibleTree="False" pe fiecare copil vizual. Cititorul de ecran va anunța containerul ca o singură unitate cu textul din Description, în loc să parcurgă copiii individual.
De ce VoiceOver nu anunță modificările din UI-ul meu MAUI?
Cititoarele de ecran nu detectează automat schimbări de conținut fără o notificare explicită. Folosește SemanticScreenReader.Default.Announce(text) pentru mesaje one-shot sau configurează un LiveRegion prin custom handler pentru actualizări continue. Verifică și că SemanticProperties.Description este re-evaluat când binding-ul se schimbă (nu doar la load).
Cum testez rapid accesibilitatea fără să activez VoiceOver sau TalkBack?
Rulează Accessibility Inspector Audit (Xcode → Open Developer Tool) pentru iOS și Accessibility Scanner (aplicație Google Play) pentru Android. Ambele generează rapoarte cu problemele detectate în UI-ul curent, fără să fie nevoie să navighezi cu screen reader activat. Pentru CI, folosește Espresso Accessibility Checks pe Android și XCTest AccessibilityAudit pe iOS 17+.
Ghid complet pentru custom handlers în .NET MAUI 2026: învață cum funcționează arhitectura pe două straturi, cum modifici mappers, cum construiești un handler nou pe iOS și Android și cum eviți memory leaks în producție.
Învață cum să folosești Dependency Injection în .NET MAUI 9 și 10 cu Microsoft.Extensions.DependencyInjection: cicluri de viață, keyed services, IHttpClientFactory și cele mai frecvente cinci greșeli, cu cod C# funcțional.