.NET MAUI Accessibility. Створюємо доступні інтерфейси

💡 Усі статті, обговорення, новини про .NET — в одному місці. Приєднуйтесь до .NET спільноти!

Привіт, спільното! Мене звати Садовий Микола, я .NET розробник у компанії GlobalLogic. Працюю з екосистемою .NET понад 7 років, а з MAUI експериментую з перших preview. Протягом останнього часу мене особливо цікавить тема (A11y) у кросплатформених додатках.

Чому це важливо?

Доступність — це не лише про людей з порушеннями зору чи моторики. Це про загальну якість UX:

  • підтримка голосових помічників (Siri, Google Assistant);
  • коректна клавіатурна навігація;
  • масштабування інтерфейсу на різних пристроях.

У цій статті ми:

  • розглянемо вбудовані інструменти MAUI для A11y;
  • створимо движок перевірки доступності (A11yAudit Engine);
  • реалізуємо 3 приклади доступних UI-сторінок.

Accessibility у .NET MAUI. Основи

Спершу розглянемо, що вже доступно «з коробки»:
1) SemanticProperties.Description — додає голосовий опис елементу для скрінрідерів:

<Entry
    Placeholder="Email"
    SemanticProperties.Description="Поле для введення електронної пошти"/>

У VoiceOver чи TalkBack користувач почує: «Поле для введення електронної пошти».

2) SemanticProperties.Hint — дозволяє додати пояснення до дії (аналог aria-describedby у web):

<Button
    Text="Надіслати"
    SemanticProperties.Hint="Надішле форму реєстрації"/>

Користувач зрозуміє, що саме виконує кнопка.

3) SemanticProperties.HeadingLevel — використовується для логічної структури заголовків:

<Label
    Text="Реєстрація"
    SemanticProperties.HeadingLevel="Level1" />

Це допомагає скрінрідерам будувати ієрархію документа.

4) AutomationProperties.HelpText — додає додаткові підказки:

<CheckBox
    Content="Приймаю умови"
    AutomationProperties.HelpText="Це обов'язкове поле"/>

Сумісність з:

  • iOS — VoiceOver;
  • Android — TalkBack;
  • Windows — Narrator.

Чого ще бракує:

  • Візуального інспектора доступності (як у Android Studio або Xcode).
  • Офіційного тулкіта для WCAG 2.1.
  • Автоматичних попереджень про відсутність атрибутів.

Тому далі ми створимо власний A11yAudit Engine.

Реалізуємо перевірки доступності. Створення A11yAudit Engine

Мета — автоматично аналізувати UI-дерево та виводити попередження, якщо відсутні ключові атрибути доступності.

Що для цього маємо зробити — розглянемо кроки:
1) Створюємо бібліотеку A11ySample.Helpers
2) Архітектура — AccessibilityAuditor + IAccessibilityCheck

public interface IAccessibilityCheck
{
    string Name { get; }
    string Validate(VisualElement element);
}
[Flags]
public enum AccessibilityAuditMode
{
    None = 0,
    Description = 1 << 0,
    Hint = 1 << 1,
    Heading = 1 << 2,
    All = Description | Hint | Heading
}
public static class AccessibilityAuditExtensions
{
    public static void RunAccessibilityAudit(this Page page, AccessibilityAuditMode mode)
    {
        if (mode == AccessibilityAuditMode.None)
            return;

        if (page is IVisualTreeElement)
        {
            var auditor = new AccessibilityAuditor();

            if (mode.HasFlag(AccessibilityAuditMode.Description))
                auditor.Register(new DescriptionCheck());

            if (mode.HasFlag(AccessibilityAuditMode.Hint))
                auditor.Register(new HintCheck());

            if (mode.HasFlag(AccessibilityAuditMode.Heading))
                auditor.Register(new HeadingCheck());

            var results = auditor.Audit(page);

            foreach (var warning in results)
            {
                System.Diagnostics.Debug.WriteLine($"[A11yAudit] : {warning}");
            }
        }
    }
}

internal class AccessibilityAuditor
{
    private readonly List<IAccessibilityCheck> _checks = new();

    public void Register(IAccessibilityCheck check) => _checks.Add(check);

    public IEnumerable<string> Audit(Page root)
    {
        if (root == null) yield break;

        foreach (var result in AuditElement(root))
            yield return result;
    }

    private IEnumerable<string> AuditElement(Element element)
    {
        if (element is VisualElement ve)
        {
            foreach (var check in _checks)
            {
                var result = check.Validate(ve);
                if (!string.IsNullOrEmpty(result))
                    yield return result;
            }
        }

        if (element is IElementController controller)
        {
            foreach (var child in controller.LogicalChildren)
            {
                if (child is ShellContent sc && sc.ContentTemplate != null)
                {
                    var page = (Page)sc.ContentTemplate.CreateContent();
                    foreach (var result in AuditElement(page))
                        yield return result;

                    (page as IDisposable)?.Dispose();
                }
                else
                {
                    foreach (var result in AuditElement(child))
                        yield return result;
                }
            }
        }
    }
}

3) Виконуємо основні перевірки:

internal class HintCheck : IAccessibilityCheck
{
    public string Name => "Hint";

    public string Validate(IVisualTreeElement element)
    {
        if (element is Button btn)
        {
            var hint = SemanticProperties.GetHint(btn);
            if (string.IsNullOrWhiteSpace(hint))
                return $"Button '{btn.Text}' doesn't have Hint";
        }

        return null;
    }
}

internal class HeadingCheck : IAccessibilityCheck
{
    public string Name => "Heading";

    public string Validate(IVisualTreeElement element)
    {
        if (element is Label lbl && SemanticProperties.GetHeadingLevel(lbl) == SemanticHeadingLevel.None)
            return $"Label '{lbl.Text}' has no header level";
        return null;
    }
}

internal class DescriptionCheck : IAccessibilityCheck
{
    public string Name => "Description";

    public string Validate(IVisualTreeElement element)
    {
        if (element is VisualElement ve)
        {
            var description = SemanticProperties.GetDescription(ve);
            if (string.IsNullOrWhiteSpace(description))
                return $"{ve.GetType().Name} doesn't have SemanticProperties.Description";
        }

        return null;
    }
}

Використання:

        public App()
        {
            InitializeComponent();

            MainPage = new AppShell();

#if DEBUG
            var shell = new AppShell();
            shell.RunAccessibilityAudit(AccessibilityAuditMode.All);
#endif

        }

Демонстраційні приклади. 3 сторінки — 3 аспекти доступності

Перший приклад: Semantic UI for Screen Readers.

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="A11ySample.Example1Page"
             Title="Example 1">
    <VerticalStackLayout Padding="20" Spacing="16">
        <Label 
            Text="User Profile"
            FontSize="32"
            SemanticProperties.HeadingLevel="Level1"
            SemanticProperties.Description="Main heading for the user profile section" />
        <Label 
            Text="Username"
            SemanticProperties.Description="Field label for entering username" />
        <Entry 
            Placeholder="Enter username"
            SemanticProperties.Description="Input field for username" />
        <Label 
            Text="Password"
            SemanticProperties.Description="Field label for entering password" />
        <Entry 
            Placeholder="Enter password"
            IsPassword="True"
            SemanticProperties.Description="Input field for password" />
        <Button 
            Text="Login"/>
    </VerticalStackLayout>
</ContentPage>

Другий приклад: Keyboard Focus & Navigation.

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="A11ySample.Example2Page"
             Title="Example 2">

    <VerticalStackLayout Padding="20" Spacing="16">

        <Label 
            Text="Keyboard Navigation"
            FontSize="28"
            SemanticProperties.HeadingLevel="Level1" />

        <Entry 
            x:Name="FirstInput"
            Placeholder="First field"
            ZIndex="0" />

        <Entry 
            x:Name="SecondInput"
            Placeholder="Second field"
            ZIndex="1" />

        <Button 
            Text="Focus First Field"
            ZIndex="2"
            Clicked="OnFocusFirstClicked" />

    </VerticalStackLayout>
</ContentPage>

    private void OnFocusFirstClicked(object sender, EventArgs e)
    {
        FirstInput.Focus();
    }

Третій приклад: Visual Accessibility.

<?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"
             x:Class="A11ySample.Example3Page"
             Title="Example 3">
    <VerticalStackLayout Padding="20" Spacing="20">
        <Label 
            Text="High Contrast Mode"
            FontSize="28"
            TextColor="Black"
            BackgroundColor="Yellow"
            SemanticProperties.HeadingLevel="Level1" />

        <Label 
            Text="This screen uses strong color contrast and large font sizes to improve readability."
            FontSize="20"
            TextColor="Black" />

        <BoxView HeightRequest="2" Color="Black" />
        <Label 
            Text="Tip: Try enabling High Contrast mode in your OS settings and zooming your text."
            FontSize="18"
            TextColor="DarkBlue" />
    </VerticalStackLayout>
</ContentPage>

Як ми бачимо, вбудований Accessibility Checker не знаходить допущених помилок.

У Visual Studio Accessibility Checker інтегрований здебільшого для WinUI/WPF/UWP. Для MAUI підтримка ще дуже обмежена: він може не ловити відсутність SemanticProperties і бачити лише базові речі (тип кнопки, текст).

Висновки

У процесі ми розібралися, що вбудовані засоби доступності у Visual Studio (Accessibility Checker у Live Visual Tree) для .NET MAUI поки що не дають повної картини. Вони працюють поверх UI Automation API конкретної платформи (WinUI, Android, iOS), і часто вважають елемент «доступним» навіть без спеціальних SemanticProperties. Наприклад, Label.Text інтерпретується як опис автоматично, а кнопка без Hint вважається валідною, бо має лише Text. Тому такий інструмент каже «No issues found», хоча з точки зору WCAG це не зовсім коректно.

Натомість наш кастомний AccessibilityAuditor вийшов більш строгим і практичним. Він перевіряє саме MAUI-рівень — тобто наявність SemanticProperties.Description, SemanticProperties.Hint, SemanticProperties.HeadingLevel. Це дозволяє виявляти ті проблеми, які реальний скрінрідер (VoiceOver, TalkBack, Narrator) потім би оголосив неправильно або неповно. Ми зробили його рекурсивним, щоб він обходив усе дерево елементів (включно з вкладеними Layout і ContentView), а також навчили запускати його через зручний extension, який викликається лише у Development-режимі. Це дає можливість інтегрувати аудит у CI або навіть у runtime-логіку для внутрішнього тестування.

Крім того, ми знайшли спосіб запускати аудит офлайн — тобто створювати сторінки з DataTemplate або Shell-контенту, проганяти перевірки і одразу їх звільняти, без показу на екрані. Це відкриває можливість написати unit-тести чи інтеграційні перевірки доступності, які проганятимуть усі сторінки застосунку ще на етапі розробки. Таким чином можна гарантувати, що жодна кнопка чи поле вводу не піде у реліз без необхідних SemanticProperties.

У результаті ми отримали повноцінний A11yAudit Engine для .NET MAUI: він гнучкий (керується через enum-флаги), строгий (виявляє реальні проблеми на рівні SemanticProperties), і зручний у використанні (extension-метод для сторінок, можливість запуску в OnAppearing, Loaded або навіть у тестах). Це рішення можна розвивати далі — наприклад, додати перевірку контрасту кольорів, розміру шрифту або навіть валідацію проти правил WCAG.

Ресурси

  1. Microsoft Learn — Accessibility in .NET MAUI: learn.microsoft.com/...​ndamentals/accessibility
  2. WCAG 2.1 Guidelines: www.w3.org/TR/WCAG21
  3. Inclusive Design Toolkit: inclusivedesigntoolkit.com
  4. github.com/...​1ty-MAUI-Helper/tree/main
👍ПодобаєтьсяСподобалось3
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі