Въведение: Защо достъпността наистина има значение
Достъпността (accessibility) в мобилните приложения не е просто хубава добавка — тя е фундаментално изискване за създаването на софтуер, който реално обслужва всички потребители. Ако не сте се замисляли сериозно за това досега, ето един факт: според данни на Световната здравна организация, над 1.3 милиарда души по света живеят с някаква форма на увреждане. Това означава, че приблизително 16% от световното население може да има затруднения с приложения, които не са проектирани с мисъл за достъпност.
Честно казано, много от нас (включително и аз) в началото пренебрегваме тази тема — защото „работи за повечето хора". Но точно тук е капанът.
В контекста на .NET MAUI (Multi-platform App UI), Microsoft предоставя богат набор от инструменти и API-та, с които можем да създаваме достъпни приложения за Android, iOS, Windows и macOS от една обща кодова база. В това ръководство ще разгледаме подробно всички аспекти на достъпността в .NET MAUI — от семантичните свойства и екранните четци до цветовия контраст и тестването.
Освен моралния императив, съществуват и законови изисквания за достъпност на софтуера. В Европейския съюз Европейският акт за достъпност (European Accessibility Act) изисква дигиталните продукти и услуги да бъдат достъпни до юни 2025 г. В САЩ Americans with Disabilities Act (ADA) и Section 508 налагат подобни стандарти. Неспазването може да доведе до съдебни дела и значителни глоби — нещо, което никой от нас не иска.
Ползите от достъпния дизайн обаче надхвърлят правните задължения:
- По-голяма потребителска база — достъпните приложения достигат до повече хора
- По-добро потребителско изживяване за всички — подобренията в достъпността често подобряват UX за всички потребители
- По-високо качество на кода — семантичната структура води до по-поддържаем код
- SEO предимства — за уеб-базирани компоненти семантичната структура подобрява индексирането
- Конкурентно предимство — достъпните приложения се отличават на пазара
WCAG 2.2: Стандартът за достъпност и мобилните приложения
Web Content Accessibility Guidelines (WCAG) 2.2 е международният стандарт за достъпност на дигиталното съдържание, публикуван от W3C. Въпреки че е създаден първоначално за уеб, принципите му се прилагат и за мобилни приложения. WCAG 2.2 е организиран около четири основни принципа, известни като POUR:
- Perceivable (Възприемаем) — информацията и компонентите на потребителския интерфейс трябва да бъдат представени по начин, който потребителите могат да възприемат
- Operable (Управляем) — компонентите на интерфейса и навигацията трябва да бъдат управляеми
- Understandable (Разбираем) — информацията и работата с интерфейса трябва да бъдат разбираеми
- Robust (Устойчив) — съдържанието трябва да бъде достатъчно устойчиво, за да се интерпретира надеждно от помощни технологии
WCAG дефинира три нива на съответствие:
- Ниво A — минимално ниво на достъпност
- Ниво AA — препоръчителното ниво (повечето законодателства изискват точно него)
- Ниво AAA — най-високото ниво на достъпност
За мобилните приложения WCAG 2.2 въвежда няколко нови критерия, които са особено важни за нас:
- 2.5.7 Dragging Movements (Ниво AA) — функционалност, изискваща плъзгане, трябва да има алтернативен начин за управление
- 2.5.8 Target Size (Minimum) (Ниво AA) — целевите области за докосване трябва да бъдат поне 24x24 CSS пиксела
- 3.3.7 Redundant Entry (Ниво A) — информация, вече въведена от потребителя, не трябва да се изисква повторно
- 3.3.8 Accessible Authentication (Minimum) (Ниво AA) — процесът на автентикация не трябва да разчита на когнитивни тестове
SemanticProperties в .NET MAUI
.NET MAUI предоставя SemanticProperties като основен механизъм за добавяне на семантична информация към визуалните елементи. Тези свойства позволяват на помощните технологии (като екранни четци) да разберат предназначението и контекста на всеки елемент от интерфейса.
Нека разгледаме отделните свойства едно по едно.
SemanticProperties.Description
Свойството Description предоставя текстово описание, което екранните четци прочитат на глас. Това е еквивалент на alt атрибута за изображения в HTML или contentDescription в Android.
<!-- XAML пример: Описание на изображение -->
<Image Source="profile_photo.png"
SemanticProperties.Description="Профилна снимка на потребителя Иван Петров"
WidthRequest="80"
HeightRequest="80" />
<!-- XAML пример: Описание на бутон с икона -->
<ImageButton Source="delete_icon.png"
SemanticProperties.Description="Изтриване на избрания елемент"
Command="{Binding DeleteCommand}"
WidthRequest="44"
HeightRequest="44" />
<!-- XAML пример: Описание на персонализиран контрол -->
<Frame SemanticProperties.Description="Карта с информация за поръчка номер 12345, статус: изпратена"
Padding="16"
CornerRadius="8">
<VerticalStackLayout>
<Label Text="Поръчка #12345" />
<Label Text="Статус: Изпратена" />
</VerticalStackLayout>
</Frame>
Същото може да се постигне програмно в C#:
// C# пример: Задаване на семантично описание
var profileImage = new Image
{
Source = "profile_photo.png",
WidthRequest = 80,
HeightRequest = 80
};
SemanticProperties.SetDescription(profileImage,
"Профилна снимка на потребителя Иван Петров");
// Динамично обновяване на описанието
var statusIcon = new Image { Source = "status_icon.png" };
SemanticProperties.SetDescription(statusIcon,
$"Статус на връзката: {(isConnected ? "Свързан" : "Прекъснат")}");
SemanticProperties.Hint
Свойството Hint дава допълнителен контекст за това какво ще се случи, когато потребителят взаимодейства с елемента. Подсказката се чете след описанието и помага на потребителя да разбере какъв ще бъде резултатът от действието.
<!-- XAML примери за Hint -->
<Button Text="Добави в количка"
SemanticProperties.Description="Добави в количка"
SemanticProperties.Hint="Добавя текущия продукт към вашата кошница за пазаруване"
Command="{Binding AddToCartCommand}" />
<Switch IsToggled="{Binding IsDarkMode}"
SemanticProperties.Description="Тъмен режим"
SemanticProperties.Hint="Превключва между светъл и тъмен режим на приложението" />
<Slider Minimum="0" Maximum="100"
Value="{Binding Volume}"
SemanticProperties.Description="Сила на звука"
SemanticProperties.Hint="Плъзнете за да регулирате силата на звука от 0 до 100 процента" />
SemanticProperties.HeadingLevel
Свойството HeadingLevel маркира елемент като заглавие с определено ниво (от 1 до 9). Това позволява на потребителите на екранни четци да навигират бързо между секциите, подобно на HTML заглавията (h1-h6). Може да звучи като дребна подробност, но от собствен опит мога да кажа — прави огромна разлика за хората, които използват VoiceOver ротора.
<!-- XAML пример: Йерархия от заглавия -->
<VerticalStackLayout Padding="16" Spacing="12">
<Label Text="Настройки на профила"
FontSize="28"
FontAttributes="Bold"
SemanticProperties.HeadingLevel="Level1" />
<Label Text="Лична информация"
FontSize="22"
FontAttributes="Bold"
SemanticProperties.HeadingLevel="Level2" />
<Entry Placeholder="Име"
Text="{Binding FirstName}"
SemanticProperties.Description="Поле за въвеждане на име" />
<Entry Placeholder="Фамилия"
Text="{Binding LastName}"
SemanticProperties.Description="Поле за въвеждане на фамилия" />
<Label Text="Настройки за известия"
FontSize="22"
FontAttributes="Bold"
SemanticProperties.HeadingLevel="Level2" />
<Label Text="Имейл известия"
FontSize="18"
FontAttributes="Bold"
SemanticProperties.HeadingLevel="Level3" />
<Switch IsToggled="{Binding EmailNotifications}"
SemanticProperties.Description="Имейл известия"
SemanticProperties.Hint="Включва или изключва получаването на имейл известия" />
</VerticalStackLayout>
Задаване на ниво на заглавие от C# код:
// C# пример: Задаване на HeadingLevel програмно
var pageTitle = new Label
{
Text = "Настройки на профила",
FontSize = 28,
FontAttributes = FontAttributes.Bold
};
SemanticProperties.SetHeadingLevel(pageTitle, SemanticHeadingLevel.Level1);
var sectionTitle = new Label
{
Text = "Лична информация",
FontSize = 22,
FontAttributes = FontAttributes.Bold
};
SemanticProperties.SetHeadingLevel(sectionTitle, SemanticHeadingLevel.Level2);
SemanticScreenReader: Програмни обявления
Класът SemanticScreenReader позволява на приложението да инструктира екранния четец да прочете съобщение на глас. Това е изключително полезно за динамични промени в съдържанието, за които потребителят трябва да разбере, без да фокусира конкретен елемент.
Основна употреба на Announce()
// Основно обявление
SemanticScreenReader.Default.Announce("Продуктът беше добавен в количката.");
// Обявление при грешка при валидация
private void OnFormSubmit()
{
var errors = ValidateForm();
if (errors.Any())
{
string errorMessage = $"Формулярът съдържа {errors.Count} грешки. " +
string.Join(". ", errors);
SemanticScreenReader.Default.Announce(errorMessage);
return;
}
SemanticScreenReader.Default.Announce("Формулярът беше изпратен успешно.");
}
// Обявление при зареждане на данни
private async Task LoadDataAsync()
{
SemanticScreenReader.Default.Announce("Зареждане на данните, моля изчакайте.");
try
{
var data = await _dataService.GetItemsAsync();
Items = new ObservableCollection<Item>(data);
SemanticScreenReader.Default.Announce(
$"Заредени са {data.Count} елемента.");
}
catch (Exception ex)
{
SemanticScreenReader.Default.Announce(
"Грешка при зареждане на данните. Моля, опитайте отново.");
}
}
Реален сценарий: достъпна кошница за пазаруване
Ето реалистичен пример с пълна реализация на достъпна страница с кошница:
public class ShoppingCartViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<CartItem> _items;
[ObservableProperty]
private decimal _totalPrice;
[RelayCommand]
private void RemoveItem(CartItem item)
{
Items.Remove(item);
TotalPrice = Items.Sum(i => i.Price * i.Quantity);
SemanticScreenReader.Default.Announce(
$"{item.Name} беше премахнат от количката. " +
$"Обща сума: {TotalPrice:C}. " +
$"Оставащи продукти: {Items.Count}.");
}
[RelayCommand]
private void UpdateQuantity(CartItem item)
{
TotalPrice = Items.Sum(i => i.Price * i.Quantity);
SemanticScreenReader.Default.Announce(
$"Количеството на {item.Name} беше променено на {item.Quantity}. " +
$"Нова обща сума: {TotalPrice:C}.");
}
[RelayCommand]
private async Task CheckoutAsync()
{
SemanticScreenReader.Default.Announce(
"Обработка на поръчката, моля изчакайте.");
bool success = await _orderService.PlaceOrderAsync(Items);
if (success)
{
SemanticScreenReader.Default.Announce(
"Поръчката ви беше направена успешно! " +
"Ще получите потвърждение по имейл.");
}
else
{
SemanticScreenReader.Default.Announce(
"Възникна проблем при обработката на поръчката. " +
"Моля, проверете данните си и опитайте отново.");
}
}
}
AutomationProperties: Фин контрол над достъпността
AutomationProperties предоставят допълнителен контрол върху начина, по който помощните технологии взаимодействат с елементите на интерфейса. Двете най-важни свойства тук са IsInAccessibleTree и ExcludedWithChildren.
AutomationProperties.IsInAccessibleTree
Това свойство определя дали даден елемент е видим за помощните технологии. По подразбиране повечето интерактивни елементи са в дървото за достъпност. Задаването на false скрива елемента от екранните четци — перфектно за чисто декоративни неща.
<!-- Скриване на декоративни елементи от екранните четци -->
<Image Source="decorative_divider.png"
AutomationProperties.IsInAccessibleTree="False"
HeightRequest="2" />
<!-- Скриване на декоративна икона, когато Label вече съдържа информацията -->
<HorizontalStackLayout Spacing="8">
<Image Source="email_icon.png"
AutomationProperties.IsInAccessibleTree="False"
WidthRequest="20"
HeightRequest="20" />
<Label Text="[email protected]"
SemanticProperties.Description="Имейл адрес: [email protected]" />
</HorizontalStackLayout>
<!-- Скриване на анимация, която не носи информация -->
<Lottie:AnimationView
Source="loading_animation.json"
AutomationProperties.IsInAccessibleTree="False"
RepeatCount="-1" />
AutomationProperties.ExcludedWithChildren
Свойството ExcludedWithChildren скрива елемента и всичките му дъщерни елементи от дървото за достъпност. Много удобно е за контейнери, обвиващи куп декоративни елементи.
<!-- Скриване на цял декоративен контейнер -->
<Grid AutomationProperties.ExcludedWithChildren="True">
<Image Source="background_pattern.png" Aspect="AspectFill" />
<BoxView Color="#80000000" />
<Image Source="decorative_overlay.png" Opacity="0.3" />
</Grid>
<!-- Групиране на елементи за по-добра достъпност -->
<!-- Вместо да четем всеки елемент поотделно, четем групата -->
<Frame SemanticProperties.Description="Продукт: Безжични слушалки, цена 89.99 лв, налични 15 броя"
Padding="12">
<Grid AutomationProperties.ExcludedWithChildren="True"
ColumnDefinitions="80, *"
RowDefinitions="Auto, Auto, Auto">
<Image Source="headphones.png" Grid.RowSpan="3" />
<Label Text="Безжични слушалки" Grid.Column="1" />
<Label Text="89.99 лв" Grid.Column="1" Grid.Row="1" />
<Label Text="Налични: 15 бр." Grid.Column="1" Grid.Row="2" />
</Grid>
</Frame>
Програмно задаване в C#:
// C# пример: Управление на AutomationProperties
var decorativeImage = new Image { Source = "pattern.png" };
AutomationProperties.SetIsInAccessibleTree(decorativeImage, false);
var decorativeContainer = new Grid();
decorativeContainer.Children.Add(new BoxView { Color = Colors.LightGray });
decorativeContainer.Children.Add(new Image { Source = "overlay.png" });
AutomationProperties.SetExcludedWithChildren(decorativeContainer, true);
// Динамично превключване на видимостта в дървото за достъпност
public void ToggleAccessibility(View element, bool isVisible)
{
AutomationProperties.SetIsInAccessibleTree(element, isVisible);
}
Мащабиране на шрифтове с FontAutoScalingEnabled
Много потребители с намалено зрение увеличават размера на шрифта в системните настройки на устройството. .NET MAUI поддържа автоматично мащабиране на шрифтовете чрез свойството FontAutoScalingEnabled, което е включено по подразбиране за повечето контроли с текст.
Звучи тривиално, но може да счупи оформлението ви ако не сте внимателни.
Как работи FontAutoScalingEnabled
Когато потребителят промени размера на шрифта в системните настройки, .NET MAUI автоматично мащабира текста. Свойството FontAutoScalingEnabled контролира дали даден елемент ще участва в това мащабиране.
<!-- FontAutoScalingEnabled е true по подразбиране -->
<Label Text="Този текст ще се мащабира автоматично"
FontSize="16" />
<!-- Изключване на автоматичното мащабиране за специфичен елемент -->
<Label Text="Този текст няма да се мащабира"
FontSize="16"
FontAutoScalingEnabled="False" />
<!-- Типичен сценарий: Заглавие на навигационна лента с фиксиран размер -->
<Label Text="Начало"
FontSize="18"
FontAutoScalingEnabled="False"
HorizontalOptions="Center" />
<!-- Съдържание, което трябва да се мащабира -->
<Label Text="Добре дошли в нашето приложение. Тук ще намерите
всичко необходимо за вашите покупки."
FontSize="16"
FontAutoScalingEnabled="True"
LineBreakMode="WordWrap" />
Добри практики за мащабиране на шрифтове
За да гарантирате, че приложението ви работи добре при увеличен шрифт, следвайте тези препоръки:
<!-- Използвайте гъвкави оформления, които се адаптират -->
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="12">
<!-- Използвайте MaxLines и LineBreakMode за контрол -->
<Label Text="{Binding ProductName}"
FontSize="20"
FontAttributes="Bold"
MaxLines="2"
LineBreakMode="TailTruncation" />
<!-- Описанието може да се разшири -->
<Label Text="{Binding ProductDescription}"
FontSize="16"
LineBreakMode="WordWrap" />
<!-- Бутон с адаптивен текст -->
<Button Text="Добави в количката"
FontSize="16"
Padding="16,12"
LineBreakMode="TailTruncation" />
</VerticalStackLayout>
</ScrollView>
За тестване на мащабирането на шрифтовете:
- Android: Настройки → Достъпност → Размер на шрифта (или Размер на показване)
- iOS: Настройки → Достъпност → Размер на текста → По-голям текст
- Windows: Настройки → Достъпност → Размер на текста
Тествайте приложението при максимален размер на шрифта, за да се уверите, че оформлението не се чупи и текстът не се отрязва. Вярвайте ми — ще откриете изненади.
Размер на целевите области за докосване
Според WCAG 2.2, критерий 2.5.8 (Target Size - Minimum), интерактивните елементи трябва да имат минимален размер от 24x24 CSS пиксела. Препоръчителният размер е значително по-голям — 44x44 точки за iOS (Apple Human Interface Guidelines) и 48x48 dp за Android (Material Design Guidelines).
Защо размерът има значение
Малките целеви области създават проблеми за множество групи потребители: хора с моторни увреждания, възрастни хора с намалена прецизност, потребители в движение, и хора с тремор. Дори и за хора без увреждания — опитайте да натиснете бутон от 16x16 пиксела в претъпкан автобус.
<!-- ЛОШО: Твърде малък бутон -->
<ImageButton Source="close_icon.png"
WidthRequest="16"
HeightRequest="16" />
<!-- ДОБРО: Минимален размер 24x24 (WCAG 2.2 AA) -->
<ImageButton Source="close_icon.png"
WidthRequest="24"
HeightRequest="24"
Padding="4"
SemanticProperties.Description="Затвори" />
<!-- НАЙ-ДОБРО: Препоръчителен размер 48x48 -->
<ImageButton Source="close_icon.png"
WidthRequest="48"
HeightRequest="48"
Padding="12"
SemanticProperties.Description="Затвори диалоговия прозорец" />
<!-- Увеличаване на целевата област без промяна на визуалния размер -->
<Grid WidthRequest="48" HeightRequest="48">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding CloseCommand}" />
</Grid.GestureRecognizers>
<Image Source="close_icon.png"
WidthRequest="20"
HeightRequest="20"
HorizontalOptions="Center"
VerticalOptions="Center"
AutomationProperties.IsInAccessibleTree="False" />
</Grid>
Стилове за осигуряване на консистентен размер
<!-- Дефиниране на глобални стилове за минимален размер -->
<ResourceDictionary>
<Style TargetType="Button">
<Setter Property="MinimumHeightRequest" Value="48" />
<Setter Property="MinimumWidthRequest" Value="48" />
<Setter Property="Padding" Value="16,12" />
</Style>
<Style TargetType="ImageButton">
<Setter Property="MinimumHeightRequest" Value="48" />
<Setter Property="MinimumWidthRequest" Value="48" />
<Setter Property="Padding" Value="12" />
</Style>
<Style x:Key="AccessibleCheckBox" TargetType="CheckBox">
<Setter Property="MinimumHeightRequest" Value="48" />
<Setter Property="MinimumWidthRequest" Value="48" />
</Style>
</ResourceDictionary>
Изисквания за цветови контраст
Цветовият контраст е един от най-важните (и най-често пренебрегваните) аспекти на визуалната достъпност. WCAG 2.2 дефинира конкретни изисквания за съотношението на контраста:
- 4.5:1 — минимално съотношение за нормален текст (под 18pt или 14pt bold)
- 3:1 — минимално съотношение за голям текст (18pt+ или 14pt+ bold) и UI компоненти
- 3:1 — минимално съотношение за графични елементи и компоненти на интерфейса
Имплементация в .NET MAUI
<!-- ЛОШО: Нисък контраст - сив текст на бял фон -->
<Label Text="Трудно четим текст"
TextColor="#999999"
BackgroundColor="#FFFFFF" />
<!-- Съотношение на контраста: 2.85:1 - НЕ отговаря на WCAG AA -->
<!-- ДОБРО: Достатъчен контраст -->
<Label Text="Лесно четим текст"
TextColor="#595959"
BackgroundColor="#FFFFFF" />
<!-- Съотношение на контраста: 7.0:1 - отговаря на WCAG AAA -->
<!-- ДОБРО: Тъмна тема с адекватен контраст -->
<Label Text="Текст в тъмна тема"
TextColor="#E0E0E0"
BackgroundColor="#121212" />
<!-- Съотношение на контраста: 13.9:1 - отговаря на WCAG AAA -->
Достъпни цветови теми
<!-- Ресурсна дефиниция за достъпни цветове -->
<ResourceDictionary>
<!-- Светла тема -->
<Color x:Key="PrimaryTextLight">#1A1A1A</Color>
<Color x:Key="SecondaryTextLight">#4A4A4A</Color>
<Color x:Key="BackgroundLight">#FFFFFF</Color>
<Color x:Key="SurfaceLight">#F5F5F5</Color>
<Color x:Key="PrimaryLight">#0056B3</Color>
<Color x:Key="ErrorLight">#C62828</Color>
<!-- Тъмна тема -->
<Color x:Key="PrimaryTextDark">#ECECEC</Color>
<Color x:Key="SecondaryTextDark">#B0B0B0</Color>
<Color x:Key="BackgroundDark">#121212</Color>
<Color x:Key="SurfaceDark">#1E1E1E</Color>
<Color x:Key="PrimaryDark">#64B5F6</Color>
<Color x:Key="ErrorDark">#EF5350</Color>
</ResourceDictionary>
Помощен клас за проверка на контраста програмно:
/// <summary>
/// Помощен клас за изчисляване и проверка на цветови контраст
/// съгласно WCAG 2.2 стандарта.
/// </summary>
public static class ContrastChecker
{
/// <summary>
/// Изчислява относителната осветеност на цвят по WCAG формулата.
/// </summary>
public static double GetRelativeLuminance(Color color)
{
double r = LinearizeChannel(color.Red);
double g = LinearizeChannel(color.Green);
double b = LinearizeChannel(color.Blue);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private static double LinearizeChannel(float channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
/// <summary>
/// Изчислява съотношението на контраста между два цвята.
/// </summary>
public static double GetContrastRatio(Color foreground, Color background)
{
double lum1 = GetRelativeLuminance(foreground);
double lum2 = GetRelativeLuminance(background);
double lighter = Math.Max(lum1, lum2);
double darker = Math.Min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
/// <summary>
/// Проверява дали контрастът отговаря на WCAG AA за нормален текст (4.5:1).
/// </summary>
public static bool MeetsWcagAA(Color foreground, Color background)
{
return GetContrastRatio(foreground, background) >= 4.5;
}
/// <summary>
/// Проверява дали контрастът отговаря на WCAG AA за голям текст (3:1).
/// </summary>
public static bool MeetsWcagAALargeText(Color foreground, Color background)
{
return GetContrastRatio(foreground, background) >= 3.0;
}
/// <summary>
/// Проверява дали контрастът отговаря на WCAG AAA за нормален текст (7:1).
/// </summary>
public static bool MeetsWcagAAA(Color foreground, Color background)
{
return GetContrastRatio(foreground, background) >= 7.0;
}
}
Важно е да не разчитате само на цвят за предаване на информация. Например, при валидация на форма не показвайте само червена рамка — добавете и текстово съобщение, и икона:
<!-- ЛОШО: Само цвят показва грешка -->
<Entry Text="{Binding Email}"
BackgroundColor="{Binding HasError, Converter={StaticResource BoolToColorConverter}}" />
<!-- ДОБРО: Цвят + текст + икона -->
<VerticalStackLayout Spacing="4">
<Entry Text="{Binding Email}"
SemanticProperties.Description="Имейл адрес"
SemanticProperties.Hint="Въведете вашия имейл адрес" />
<HorizontalStackLayout Spacing="4"
IsVisible="{Binding HasEmailError}">
<Image Source="error_icon.png"
WidthRequest="16" HeightRequest="16"
AutomationProperties.IsInAccessibleTree="False" />
<Label Text="{Binding EmailErrorMessage}"
TextColor="{StaticResource ErrorLight}"
FontSize="12"
SemanticProperties.Description="{Binding EmailErrorMessage}" />
</HorizontalStackLayout>
</VerticalStackLayout>
Специфики на платформите: TalkBack, VoiceOver и Narrator
Въпреки че .NET MAUI абстрахира голяма част от платформените различия, познаването на спецификите на всеки екранен четец е от съществено значение. Всеки от тях има своите особености и капризи.
TalkBack (Android)
TalkBack е екранният четец на Android. Основните жестове включват:
- Плъзгане наляво/надясно — навигация между елементите
- Двойно докосване — активиране на избрания елемент
- Плъзгане с два пръста — превъртане
- L-образен жест — отваряне на контекстното меню на TalkBack
За Android специфични настройки в .NET MAUI:
// Платформено-специфична конфигурация за Android
#if ANDROID
using Android.Views.Accessibility;
public partial class CustomAccessibleView : ContentView
{
partial void PlatformSetup()
{
var handler = Handler?.PlatformView;
if (handler != null)
{
handler.ImportantForAccessibility =
Android.Views.ImportantForAccessibility.Yes;
handler.AccessibilityLiveRegion =
Android.Views.AccessibilityLiveRegion.Polite;
}
}
}
#endif
VoiceOver (iOS)
VoiceOver е екранният четец на Apple за iOS и iPadOS. Ключови жестове:
- Плъзгане наляво/надясно — навигация между елементите
- Двойно докосване — активиране на елемента
- Завъртане с два пръста (ротор) — избор на режим на навигация (заглавия, връзки и др.)
- Плъзгане нагоре/надолу с три пръста — превъртане
VoiceOver има специална функционалност — ротор, която позволява навигация по заглавия. Точно затова SemanticProperties.HeadingLevel е толкова важен за iOS.
// Платформено-специфична конфигурация за iOS
#if IOS
using UIKit;
public partial class CustomAccessibleView : ContentView
{
partial void PlatformSetup()
{
var handler = Handler?.PlatformView;
if (handler != null)
{
handler.IsAccessibilityElement = true;
handler.AccessibilityTraits = UIAccessibilityTrait.Button;
// Дефиниране на персонализирани действия за VoiceOver
handler.AccessibilityCustomActions = new[]
{
new UIAccessibilityCustomAction(
"Изтрий",
(action) => { DeleteItem(); return true; }),
new UIAccessibilityCustomAction(
"Редактирай",
(action) => { EditItem(); return true; })
};
}
}
}
#endif
Narrator (Windows)
Narrator е екранният четец на Windows. Той поддържа навигация с клавиатура:
- Caps Lock + стрелки — навигация между елементите
- Enter/Space — активиране на елемента
- Caps Lock + H — навигация по заглавия
- Caps Lock + T — четене на заглавието на прозореца
За Windows .NET MAUI приложения, Narrator работи с UIA (UI Automation) дървото, което се генерира автоматично от рамката на базата на зададените семантични свойства. Тук нямате нужда от допълнителни настройки в повечето случаи.
Клавиатурна и алтернативна навигация
Много потребители не могат да използват сензорен екран и разчитат на клавиатура, превключватели (switches) или други алтернативни входни устройства. Осигуряването на пълна клавиатурна навигация е задължително по WCAG 2.2 (критерий 2.1.1 Keyboard, Ниво A).
Управление на реда на фокуса (Tab Order)
<!-- Дефиниране на логичен ред на табулация -->
<VerticalStackLayout>
<Label Text="Форма за регистрация"
SemanticProperties.HeadingLevel="Level1"
FontSize="24" />
<Entry Placeholder="Потребителско име"
TabIndex="1"
SemanticProperties.Description="Потребителско име"
SemanticProperties.Hint="Въведете желаното потребителско име" />
<Entry Placeholder="Имейл адрес"
TabIndex="2"
Keyboard="Email"
SemanticProperties.Description="Имейл адрес"
SemanticProperties.Hint="Въведете вашия имейл адрес" />
<Entry Placeholder="Парола"
TabIndex="3"
IsPassword="True"
SemanticProperties.Description="Парола"
SemanticProperties.Hint="Въведете парола с минимум 8 символа" />
<Entry Placeholder="Потвърдете паролата"
TabIndex="4"
IsPassword="True"
SemanticProperties.Description="Потвърждение на паролата"
SemanticProperties.Hint="Въведете паролата отново за потвърждение" />
<Button Text="Регистрация"
TabIndex="5"
Command="{Binding RegisterCommand}"
SemanticProperties.Hint="Натиснете за да създадете нов акаунт" />
<Button Text="Вече имам акаунт"
TabIndex="6"
Command="{Binding NavigateToLoginCommand}"
SemanticProperties.Hint="Отива на страницата за вход в съществуващ акаунт" />
</VerticalStackLayout>
Програмно управление на фокуса
// Преместване на фокуса програмно
public partial class LoginPage : ContentPage
{
private async void OnUsernameCompleted(object sender, EventArgs e)
{
// Преместване на фокуса към полето за парола
PasswordEntry.Focus();
}
private async void OnLoginFailed(string errorMessage)
{
// Показване на грешка и преместване на фокуса
ErrorLabel.Text = errorMessage;
ErrorLabel.IsVisible = true;
// Обявяване на грешката за екранни четци
SemanticScreenReader.Default.Announce(errorMessage);
// Преместване на фокуса обратно към потребителското име
UsernameEntry.Focus();
}
// Управление на фокуса при навигация
protected override void OnAppearing()
{
base.OnAppearing();
// Задаване на начален фокус
MainThread.BeginInvokeOnMainThread(() =>
{
UsernameEntry.Focus();
SemanticScreenReader.Default.Announce(
"Страница за вход. Моля, въведете вашите данни.");
});
}
}
Достъпни персонализирани жестове
Когато използвате персонализирани жестове, винаги осигурявайте алтернативен начин за същото действие. Това е едно от нещата, които лесно се забравят:
<!-- Swipe за изтриване с алтернативен бутон -->
<SwipeView>
<SwipeView.RightItems>
<SwipeItems>
<SwipeItem Text="Изтрий"
BackgroundColor="Red"
Command="{Binding DeleteCommand}"
SemanticProperties.Description="Изтриване на елемента" />
</SwipeItems>
</SwipeView.RightItems>
<Grid Padding="16" ColumnDefinitions="*, Auto">
<Label Text="{Binding ItemName}"
VerticalOptions="Center" />
<!-- Видим бутон за изтриване като алтернатива на swipe -->
<ImageButton Source="delete_icon.png"
Grid.Column="1"
Command="{Binding DeleteCommand}"
WidthRequest="48"
HeightRequest="48"
SemanticProperties.Description="Изтриване"
SemanticProperties.Hint="Изтрива текущия елемент от списъка" />
</Grid>
</SwipeView>
Инструменти за тестване на достъпността
Тестването е многопластов процес, който изисква комбинация от автоматизирани инструменти и ръчно тестване. Нито едното, нито другото самостоятелно не е достатъчно.
Accessibility Inspector (iOS/macOS)
Accessibility Inspector е инструмент на Apple, вграден в Xcode. Той позволява:
- Инспектиране на свойствата за достъпност на всеки елемент
- Автоматично сканиране за често срещани проблеми
- Симулиране на VoiceOver навигация
- Проверка на контраста на цветовете
За да го използвате с .NET MAUI приложение:
- Стартирайте приложението на iOS симулатор
- Отворете Xcode → Open Developer Tool → Accessibility Inspector
- Изберете симулатора от падащото меню
- Използвайте бутона за инспектиране, за да прегледате елементите
- Стартирайте Audit за автоматична проверка
Accessibility Scanner (Android)
Google Accessibility Scanner е приложение за Android, което анализира потребителския интерфейс и предоставя конкретни предложения:
- Проверка на размера на целевите области за докосване
- Анализ на цветовия контраст
- Откриване на елементи без описание за достъпност
- Предложения за подобрения с конкретни препоръки
За да тествате с Accessibility Scanner:
- Инсталирайте Accessibility Scanner от Google Play Store
- Активирайте го от Настройки → Достъпност
- Отворете вашето .NET MAUI приложение
- Натиснете плаващия бутон на Scanner за анализ
- Прегледайте намерените проблеми и предложените корекции
Ръчно тестване с екранни четци
Автоматичните инструменти не могат да уловят всички проблеми. Ръчното тестване с реални екранни четци е незаменимо. Ето контролен списък, който използвам в моите проекти:
// Контролен списък за ръчно тестване (като коментари в кода)
// 1. Навигация с екранен четец
// [ ] Всички интерактивни елементи са достъпни чрез плъзгане
// [ ] Редът на навигация е логичен
// [ ] Заглавията са правилно маркирани
// [ ] Навигацията по заглавия работи (VoiceOver ротор)
// 2. Описания и подсказки
// [ ] Всички изображения имат описание (или са маркирани като декоративни)
// [ ] Бутоните с икони имат текстово описание
// [ ] Подсказките (Hint) са информативни и точни
// [ ] Динамичните промени се обявяват
// 3. Формуляри
// [ ] Полетата имат достъпни етикети
// [ ] Грешките при валидация се обявяват
// [ ] Фокусът се управлява правилно
// [ ] Клавиатурата не закрива активното поле
// 4. Визуална достъпност
// [ ] Контрастът отговаря на WCAG AA (4.5:1 за текст)
// [ ] Текстът се мащабира правилно при увеличен шрифт
// [ ] Оформлението не се чупи при увеличен шрифт
// [ ] Информацията не се предава само чрез цвят
// 5. Интерактивни елементи
// [ ] Целевите области са достатъчно големи (мин. 24x24, препоръчано 48x48)
// [ ] Swipe действията имат алтернативи
// [ ] Таймаутите са достатъчно дълги или могат да се удължат
Интеграция на тестове за достъпност в CI/CD
Автоматизираните тестове няма да хванат всичко, но пък ще ви предпазят от регресии. Ето как можете да интегрирате проверки за достъпност в CI/CD:
// Unit тест за проверка на семантични свойства
[TestClass]
public class AccessibilityTests
{
[TestMethod]
public void ProductCard_ShouldHaveSemanticDescription()
{
// Arrange
var viewModel = new ProductViewModel
{
Name = "Безжични слушалки",
Price = 89.99m
};
// Act
var card = new ProductCard { BindingContext = viewModel };
// Assert
var description = SemanticProperties.GetDescription(card);
Assert.IsFalse(string.IsNullOrEmpty(description),
"ProductCard трябва да има семантично описание.");
}
[TestMethod]
public void AllButtons_ShouldMeetMinimumTouchTargetSize()
{
// Arrange
var page = new MainPage();
var buttons = FindAllDescendants<Button>(page);
// Assert
foreach (var button in buttons)
{
Assert.IsTrue(
button.MinimumHeightRequest >= 24 || button.HeightRequest >= 24,
$"Бутон '{button.Text}' не отговаря на минималния размер за докосване.");
Assert.IsTrue(
button.MinimumWidthRequest >= 24 || button.WidthRequest >= 24,
$"Бутон '{button.Text}' не отговаря на минималния размер за докосване.");
}
}
[TestMethod]
public void ColorPairs_ShouldMeetWcagAAContrast()
{
// Проверка на основните цветови комбинации
var pairs = new[]
{
(foreground: Color.FromArgb("#1A1A1A"),
background: Color.FromArgb("#FFFFFF"),
name: "Primary text on white"),
(foreground: Color.FromArgb("#4A4A4A"),
background: Color.FromArgb("#FFFFFF"),
name: "Secondary text on white"),
(foreground: Color.FromArgb("#ECECEC"),
background: Color.FromArgb("#121212"),
name: "Primary text on dark")
};
foreach (var pair in pairs)
{
double ratio = ContrastChecker.GetContrastRatio(
pair.foreground, pair.background);
Assert.IsTrue(ratio >= 4.5,
$"Цветовата комбинация '{pair.name}' има контраст {ratio:F2}:1, " +
$"което е под изискваното 4.5:1 за WCAG AA.");
}
}
private IEnumerable<T> FindAllDescendants<T>(Element parent) where T : Element
{
var results = new List<T>();
foreach (var child in (parent as IVisualTreeElement)?.GetVisualChildren()
?? Enumerable.Empty<IVisualTreeElement>())
{
if (child is T typedChild)
results.Add(typedChild);
if (child is Element element)
results.AddRange(FindAllDescendants<T>(element));
}
return results;
}
}
Често срещани грешки и как да ги избегнете
Нека разгледаме най-честите грешки при разработката на достъпни .NET MAUI приложения. Виждал съм всяка от тях в реални проекти (и да, допускал съм някои от тях и аз).
Грешка 1: Липса на описания за изображения и бутони с икони
<!-- ГРЕШКА: Бутон с икона без описание -->
<ImageButton Source="settings.png" />
<!-- КОРЕКЦИЯ: Добавяне на семантично описание -->
<ImageButton Source="settings.png"
SemanticProperties.Description="Настройки"
SemanticProperties.Hint="Отваря страницата с настройки на приложението" />
Грешка 2: Дублиране на информация за екранни четци
<!-- ГРЕШКА: Екранният четец ще прочете "Запази Запази бутон" -->
<Button Text="Запази"
SemanticProperties.Description="Запази" />
<!-- КОРЕКЦИЯ: Не задавайте Description, когато Text вече е достатъчен -->
<Button Text="Запази"
SemanticProperties.Hint="Записва промените в профила ви" />
Грешка 3: Неинформативни описания
Това е класика. „Изображение" не казва абсолютно нищо на потребителя.
<!-- ГРЕШКА: Описанието не е полезно -->
<Image Source="chart.png"
SemanticProperties.Description="Изображение" />
<!-- КОРЕКЦИЯ: Описание, което предава съдържанието -->
<Image Source="chart.png"
SemanticProperties.Description="Графика на продажбите за 2024 г.,
показваща ръст от 25% спрямо предходната година" />
Грешка 4: Неправилно скриване на декоративни елементи
<!-- ГРЕШКА: Декоративно изображение е видимо за екранни четци -->
<Image Source="divider_line.png" />
<!-- КОРЕКЦИЯ: Маркиране като невидимо за помощни технологии -->
<Image Source="divider_line.png"
AutomationProperties.IsInAccessibleTree="False" />
Грешка 5: Липса на обявления при динамични промени
// ГРЕШКА: Потребителят на екранен четец не знае, че нещо се е променило
private async Task RefreshData()
{
Items = await _service.GetItemsAsync();
}
// КОРЕКЦИЯ: Обявяване на промяната
private async Task RefreshData()
{
SemanticScreenReader.Default.Announce("Обновяване на данните...");
Items = await _service.GetItemsAsync();
SemanticScreenReader.Default.Announce(
$"Данните бяха обновени. Показани са {Items.Count} елемента.");
}
Грешка 6: Недостъпни персонализирани контроли
Персонализираните контроли са може би най-проблемната област. Ето типичен пример с рейтинг контрол:
<!-- ГРЕШКА: Персонализиран контрол без семантика за достъпност -->
<Grid>
<Grid.GestureRecognizers>
<TapGestureRecognizer Tapped="OnRatingTapped" />
</Grid.GestureRecognizers>
<HorizontalStackLayout>
<Image Source="star_filled.png" />
<Image Source="star_filled.png" />
<Image Source="star_filled.png" />
<Image Source="star_empty.png" />
<Image Source="star_empty.png" />
</HorizontalStackLayout>
</Grid>
<!-- КОРЕКЦИЯ: Добавяне на пълна семантика -->
<Grid SemanticProperties.Description="Оценка: 3 от 5 звезди"
SemanticProperties.Hint="Докоснете за да промените оценката">
<Grid.GestureRecognizers>
<TapGestureRecognizer Tapped="OnRatingTapped" />
</Grid.GestureRecognizers>
<HorizontalStackLayout AutomationProperties.ExcludedWithChildren="True">
<Image Source="star_filled.png" />
<Image Source="star_filled.png" />
<Image Source="star_filled.png" />
<Image Source="star_empty.png" />
<Image Source="star_empty.png" />
</HorizontalStackLayout>
</Grid>
Грешка 7: Игнориране на мащабирането при критични елементи
<!-- ГРЕШКА: Фиксирана височина, която не позволява на текста да се разшири -->
<Frame HeightRequest="50">
<Label Text="Дълъг текст, който може да нарасне при увеличен шрифт"
FontSize="16" />
</Frame>
<!-- КОРЕКЦИЯ: Минимална височина вместо фиксирана -->
<Frame MinimumHeightRequest="50">
<Label Text="Дълъг текст, който може да нарасне при увеличен шрифт"
FontSize="16"
LineBreakMode="WordWrap" />
</Frame>
Обобщение на най-добрите практики
И така, ето какво трябва да запомните:
- Задавайте семантични свойства на всички значими елементи — всяко изображение, бутон с икона и интерактивен елемент трябва да има описание
- Скривайте декоративните елементи — използвайте
AutomationProperties.IsInAccessibleTree="False"за елементи без информационна стойност - Групирайте свързани елементи — вместо да четете пет отделни текста, групирайте ги с едно обобщено описание
- Обявявайте динамичните промени — използвайте
SemanticScreenReader.Default.Announce()за информиране при промени - Тествайте с реални екранни четци — автоматичните инструменти не улавят всичко
- Осигурете достатъчен контраст — 4.5:1 за текст, 3:1 за UI компоненти
- Използвайте достатъчно големи целеви области — минимум 24x24, препоръчано 48x48 пиксела
- Поддържайте мащабиране на шрифтове — не деактивирайте
FontAutoScalingEnabledбез основателна причина - Осигурете клавиатурна навигация — задайте логичен
TabIndexи управлявайте фокуса програмно - Не разчитайте само на цвят — добавяйте текст или икони като допълнителен индикатор
- Използвайте заглавия —
SemanticProperties.HeadingLevelпозволява бърза навигация - Тествайте при максимален шрифт — увеличете шрифта до максимум и проверете оформлението
Цялостен пример: Достъпна страница с продукт
За да обединим всичко от ръководството, нека разгледаме пример за напълно достъпна страница с детайли за продукт. Това е нещо, което можете да вземете и адаптирате за вашия проект:
<?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="MyShop.Views.ProductDetailPage"
Title="Детайли за продукта">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="16">
<!-- Заглавие на продукта -->
<Label Text="{Binding Product.Name}"
FontSize="28"
FontAttributes="Bold"
SemanticProperties.HeadingLevel="Level1" />
<!-- Изображение на продукта -->
<Image Source="{Binding Product.ImageUrl}"
HeightRequest="250"
Aspect="AspectFit"
SemanticProperties.Description="{Binding Product.ImageDescription}" />
<!-- Цена и наличност -->
<HorizontalStackLayout Spacing="12">
<Label Text="{Binding Product.Price, StringFormat='{0:C}'}"
FontSize="24"
FontAttributes="Bold"
TextColor="{StaticResource PrimaryLight}"
SemanticProperties.Description="{Binding PriceAccessibilityText}" />
<Frame BackgroundColor="{Binding AvailabilityColor}"
Padding="8,4"
CornerRadius="4"
SemanticProperties.Description="{Binding AvailabilityAccessibilityText}">
<Label Text="{Binding AvailabilityText}"
TextColor="White"
FontSize="12"
AutomationProperties.IsInAccessibleTree="False" />
</Frame>
</HorizontalStackLayout>
<!-- Описание -->
<Label Text="Описание"
FontSize="20"
FontAttributes="Bold"
SemanticProperties.HeadingLevel="Level2" />
<Label Text="{Binding Product.Description}"
FontSize="16"
LineBreakMode="WordWrap" />
<!-- Избор на количество -->
<Label Text="Количество"
FontSize="20"
FontAttributes="Bold"
SemanticProperties.HeadingLevel="Level2" />
<HorizontalStackLayout Spacing="12"
HorizontalOptions="Start">
<Button Text="-"
Command="{Binding DecreaseQuantityCommand}"
WidthRequest="48"
HeightRequest="48"
SemanticProperties.Description="Намаляване на количеството"
SemanticProperties.Hint="Намалява количеството с единица" />
<Label Text="{Binding Quantity}"
FontSize="20"
VerticalOptions="Center"
MinimumWidthRequest="48"
HorizontalTextAlignment="Center"
SemanticProperties.Description="{Binding Quantity,
StringFormat='Текущо количество: {0}'}" />
<Button Text="+"
Command="{Binding IncreaseQuantityCommand}"
WidthRequest="48"
HeightRequest="48"
SemanticProperties.Description="Увеличаване на количеството"
SemanticProperties.Hint="Увеличава количеството с единица" />
</HorizontalStackLayout>
<!-- Бутон за добавяне в количката -->
<Button Text="Добави в количката"
Command="{Binding AddToCartCommand}"
FontSize="18"
Padding="16,14"
MinimumHeightRequest="48"
BackgroundColor="{StaticResource PrimaryLight}"
TextColor="White"
SemanticProperties.Hint="{Binding AddToCartHint}" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
И съответният ViewModel с достъпни обявления:
public partial class ProductDetailViewModel : ObservableObject
{
[ObservableProperty]
private Product _product;
[ObservableProperty]
private int _quantity = 1;
public string PriceAccessibilityText =>
$"Цена: {Product?.Price:C}";
public string AvailabilityAccessibilityText =>
Product?.IsInStock == true
? "Наличен в склада"
: "Изчерпан, очаква се доставка";
public string AvailabilityText =>
Product?.IsInStock == true ? "В наличност" : "Изчерпан";
public Color AvailabilityColor =>
Product?.IsInStock == true
? Color.FromArgb("#2E7D32")
: Color.FromArgb("#C62828");
public string AddToCartHint =>
$"Добавя {Quantity} броя {Product?.Name} в кошницата за пазаруване";
[RelayCommand]
private void IncreaseQuantity()
{
if (Quantity < Product.MaxQuantity)
{
Quantity++;
SemanticScreenReader.Default.Announce(
$"Количество: {Quantity}");
}
else
{
SemanticScreenReader.Default.Announce(
$"Достигнато е максималното количество от {Product.MaxQuantity} броя.");
}
}
[RelayCommand]
private void DecreaseQuantity()
{
if (Quantity > 1)
{
Quantity--;
SemanticScreenReader.Default.Announce(
$"Количество: {Quantity}");
}
else
{
SemanticScreenReader.Default.Announce(
"Минималното количество е 1.");
}
}
[RelayCommand]
private async Task AddToCart()
{
SemanticScreenReader.Default.Announce("Добавяне в количката...");
bool success = await _cartService.AddAsync(Product, Quantity);
if (success)
{
SemanticScreenReader.Default.Announce(
$"{Quantity} броя {Product.Name} бяха добавени в количката. " +
$"Обща сума в количката: {_cartService.TotalPrice:C}.");
}
else
{
SemanticScreenReader.Default.Announce(
"Грешка при добавяне в количката. Моля, опитайте отново.");
}
}
}
Заключение
Достъпността в .NET MAUI приложенията не е опция — тя е необходимост. С нарастващите законови изисквания и етичните съображения, всеки разработчик трябва да интегрира практиките за достъпност в ежедневния си работен процес.
.NET MAUI ни дава доста изчерпателен набор от инструменти. SemanticProperties осигуряват семантична информация за екранните четци, AutomationProperties дават фин контрол над дървото за достъпност, а SemanticScreenReader позволява програмни обявления за динамични промени.
Ключовите принципи, които трябва да запомните:
- Проектирайте с мисъл за достъпност от самото начало — добавянето й след факта е многократно по-скъпо и по-малко ефективно
- Следвайте WCAG 2.2 стандарта — той предоставя ясни, измерими критерии за съответствие
- Тествайте с реални помощни технологии — TalkBack на Android, VoiceOver на iOS и Narrator на Windows
- Автоматизирайте когато е възможно — включете проверки за достъпност в CI/CD процеса
- Включете потребители с увреждания в тестването — никой инструмент не може да замени реалния потребителски опит
Достъпността е пътуване, а не дестинация. Всяко малко подобрение прави приложението ви по-достъпно за повече хора. Започнете с основите — семантични описания, адекватен контраст и достатъчно големи целеви области — и постепенно усъвършенствайте всичко с всяка нова версия.
Като .NET MAUI разработчици имаме уникалната възможност да напишем код веднъж и да осигурим достъпност на множество платформи едновременно. Нека не пропускаме тази възможност.