Нередко бывает такое, что у пользователей есть какие-либо физические или возрастные ограничения, которые влияют на работу с приложением.
Из-за ограничений слуха они не смогут посмотреть видео без субтитров или услышать звуковое уведомление, а из-за ограничений зрения — взаимодействовать с интерфейсом приложения.
Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности (англ*.* accessibility). Доступность приложения тесно связана с понятием универсального дизайна — концепцией, которая гласит, что продукт должен быть доступен для всех вне зависимости от возраста, возможностей здоровья и других факторов.
В этой статье мы рассмотрим популярные практики и инструменты во Flutter, с помощью которых приложение можно сделать максимально доступным.
Измененный размер шрифта
Изменением размера шрифта часто пользуются пожилые и люди с проблемами зрения. Это системная настройка, которая позволяет как увеличивать размер текста, так и уменьшать его.
В коде можно получить значение изменения шрифта через MediaQuery: MediaQuery.of(context).textScaleFactor.
Стандартное значение для textScaleFactor без дополнительных настроек равно 1,0. На скринах ниже оно эквивалентно 0,88 и 3,12 соответственно.
textScaleFactor 0,88 (слева)
textScaleFactor 3,12 (справа)
Можно заметить, что на втором скриншоте размер текста аж в три раза больше, чем стандартный. Очень немногие приложения готовы поддерживать настолько большие шрифты, так что приходится ставить ограничения. Вспомним, что MediaQuery — это InheritedWidget, а значит, мы сможем указать свои параметры:
1@override
2 Widget build(BuildContext context) => MaterialApp(
3 builder: (context, _) {
4 final mediaQueryData = MediaQuery.of(context);
5 return MediaQuery(
6 data: mediaQueryData.copyWith(
7 // Переопределяем значение textScaleFactor
8 textScaleFactor: min(mediaQueryData.textScaleFactor, 1.5),
9 ),
10 child: const HomePage(),
11 );
12 },
13 );
Таким образом мы переопределили поведение: указали, что максимальный textScaleFactor, который мы готовы поддерживать, равен 1,5.
Учтите, что при указании
fontSizeвTextStyleв таких виджетах, как, например,Text,textScaleFactorтакже будет применяться, так что конечный размер текста будет на самом деле равен произведениюfontSizeиtextScaleFactor.
Важно! Стоит аккуратнее переопределять textScaleFactor. Если указать переопределение textScaleFactor: 1 (запретить изменение шрифта), то пользователям с нарушениями зрения будет некомфортно, и они, вероятно, удалят приложение.
Контрастность
При проблемах с цветоощущением, дальтонизме или слабом зрении бывает сложно прочитать текст на ярком фоне или бледный текст. Есть общее правило, которое гласит, что при проектировании интерфейса стоит ориентироваться не на цвет, а на то, насколько контрастны цвета, в большей степени это касается читаемости текста.
В настройках специальных возможностей также есть два пункта, которые помогают решить эту проблему:
Увеличение контрастности
Повышает контрастность между цветами переднего и заднего планов приложения. Эту настройку можно получить из MediaQuery: MediaQuery.of(context).invertColors и переопределить через InheritedWidget:
1@override
2Widget build(BuildContext context) => MaterialApp(
3 builder: (context, _) {
4 final mediaQueryData = MediaQuery.of(context);
5 return MediaQuery(
6 data: mediaQueryData.copyWith(
7 invertColors: false, // Переопределяем значение invertColors
8 ),
9 child: const HomePage(),
10 );
11 },
12 );
Флаг контрастности invertColors в данный момент поддержан только на iOS.
Жирный шрифт
Делает все шрифты устройства жирными. Эту настройку можно получить из MediaQuery: MediaQuery.of(context).boldText и аналогично переопределить через InheritedWidget:
1@override
2 Widget build(BuildContext context) => MaterialApp(
3 builder: (context, _) {
4 final mediaQueryData = MediaQuery.of(context);
5 return MediaQuery(
6 data: mediaQueryData.copyWith(
7 boldText: false, // Переопределяем значение boldText
8 ),
9 child: const HomePage(),
10 );
11 },
12 );
Важно! Как и с textScaleFactor, стоит использовать переопределение boldText и invertColors аккуратно.
Флаги
invertTextиboldTextможно получить не только изMediaQuery, но ещё и изPlatformDispatcher.accessibilityFeaturesиз библиотекиdart:ui.
А вот так выглядит включение этих опций на примере приложения настроек:
Включен жирный шрифт (по центру)
Без дополнительных настроек (справа)
Больше про правила контрастности в приложении можно почитать на официальном сайте Material.
Для проверки контрастности цветов можно использовать инструмент Contrast checker. Он сопоставляет фон и цвет текста и оценивает контрастность интерфейса по десятибалльной шкале.

Поддержка чтения экрана
Чтение экрана — функция, которая помогает использовать мобильное приложение людям с ослабленным зрением.
Для этого на iOS и Android есть два приложения: VoiceOver и Talkback. Они устроены по схожей схеме: имеется набор жестов (касание, двойное и тройное касание, смахивания влево, вправо, вверх и вниз), а также набор действий, который запускается при выполнении одного из действий.
Например, касание элемента приводит к звуковому сопровождению, которое описывает, чем элемент является, и зачитывает его текстовое описание, если такое есть. Двойное касание ведёт к выполнению действия, которое поддерживает элемент.
Помимо VoiceOver и Talkback существуют и много инструментов, которые позволяют делать устройство доступным: Voice Access, Text to Speech, Select to Speech, Magnification и другие. В рамках этой статьи мы их рассматривать не будем, потому что они являются специфичными для конкретного устройства и не требуют дополнительных настроек со стороны разработчика.
Если запустить шаблонное приложение, которое появляется при создании приложения с помощью команды flutter create <name>, и опробовать на нем функцию VoiceOver или Talkback, то оказывается, что все элементы интерфейса поддерживают функциональность чтения экрана, хотя дополнительные доработки для этого не делались.
Пример работы VoiceOver с шаблоном
Большинство виджетов фреймворка поддерживают необходимое описание для работы функций чтения экрана. Это может быть как обычный текст с дополнительной информацией, так и описание элемента и действия, которое выполняется при взаимодействии с этим элементом. Чуть ниже мы рассмотрим примеры реализаций из самого фреймворка.
Semantics
В части фреймворка semantics содержится функциональность для работы с доступностью приложения. Большая часть классов и методов размещена во flutter/lib/src/semantics/ и объединена экспортом package:flutter/semantics.dart.
Он включает:
semanticsBindingSemanticsServiceSemanticsEventSemanticsNode,SemanticsConfigurationSemanticsBlockSemantics,MergeSemantics,ExcludeSemanticsSemanticsв debug.
О SemanticsBinding мы уже рассказывали в параграфе «Сервисы связи», останавливаться на нём тут не будем — скажем только, что это «клей» между движком и слоем семантики.
А остальные рассмотрим подробнее.
SemanticsService
Специфичный для платформы код, который работает с платформенными реализациями функциональности доступности. Содержит два метода — announce() и tooltip().
announce позволяет отправить семантическое оповещение.
```dart
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
home: Scaffold(
body: Center(
child: OutlinedButton(
child: const Text('Announce message'),
onPressed: () => SemanticsService.announce(
'My message',
TextDirection.ltr,
),
),
),
),
);
}
1
2{% endcut %}
3
4<iframe width="100%" height="500" src="https://frontend.vh.yandex.ru/player/49e933714d1c1031beecff08c1f5e85b?from=partner&mute=1&autoplay=1&tv=0&loop=true&play_on_visible=false" allow="autoplay; fullscreen; accelerometer; gyroscope; picture-in-picture; encrypted-media" frameborder="0" scrolling="no" allowfullscreen></iframe>
5
6[`tooltip`](https://api.flutter.dev/flutter/semantics/SemanticsService/tooltip.html)позволяет отправить семантическое оповещение для тултипов.
7
8Поддерживается только Android платформой и вызывается в коде фреймворка только один раз — после показа виджета `Tooltip`. Выглядит как всплывающая системная подсказка, позволяющая уведомить пользователя.
9
10
11
12> `SemanticsService` используется в достаточно специфичных случаях. Большая часть объявлений об изменении пользовательского интерфейса и нажатиях покрывается уже имеющимся возможностями устройства.
13>
14> Однако бывают крайние кейсы, которые нужно дополнительно озвучивать. Хорошим примером является изменение фокуса в приложении камеры. Если включен VoiceOver и открыто приложение камеры, то каждое изменение фокуса будет сопровождаться голосом.
15
16
17**SemanticsEvent**
18
19События, которые отправляются приложением и обозначающие действия, которые делает пользователь. Используется для отправки в нативную часть приложения на стороне движка.
20
21Каждый Event содержит поле type, который можно считать аналогом [`UIAccessibility Notification`](https://developer.apple.com/documentation/uikit/uiaccessibility/notification) на iOS и [`AccessibilityEvent`](https://developer.android.com/reference/android/view/accessibility/AccessibilityEvent) на Android.
22
23```dart
24/// Абстрактный класс, который реализуют все события.
25abstract class SemanticsEvent {
26 const SemanticsEvent(this.type);
27
28 /// Тип события, аналог UIAccessibility Notification/AccessibilityEvent
29 final String type;
30
31 /// Преобразует событие в Map, которая может быть закодирована с помощью
32 /// [StandardMessageCodec].
33 Map<String, dynamic> toMap({ int? nodeId }) {
34 final Map<String, dynamic> event = <String, dynamic>{
35 'type': type,
36 'data': getDataMap(),
37 };
38 if (nodeId != null) {
39 event['nodeId'] = nodeId;
40 }
41
42 return event;
43 }
44
45 /// Преобразует данные события в Map, используется методом toMap.
46 Map<String, dynamic> getDataMap();
47}
Вот реализации для различных событий:
-
AnnounceSemanticsEvent. Событие, которое имеет типannounceи используетсяSemanticsServiceв методеannounce, который мы рассмотрели ранее. Этот события используется для уведомления платформы о том, что действие следует озвучит. -
TooltipSemanticsEvent. Событие, которое имеет типtooltipи используетсяSemanticsServiceв методеtooltip, который мы также рассмотрели ранее. Этот события используется для уведомления платформы о том, что следует показать подсказку. -
LongPressSemanticsEvent. Событие, которое имеет типlongPress. Используется в классеFeedbackв методеforLongPress, который отправляет событие через платформенный каналSystemChannels.accessibilityс именемflutter/accessibility. На устройстве после долгого нажатия это событие вызывает несколько слабых толчков вибрации.Это событие используется в нескольких виджетах, например в
InkWell. ЕслиenableFeedbackне был указан как параметр (т. е. онnull) илиenableFeedbackбылtrueи был указан параметрonLongPress, вызываетсяFeedback.forLongPress, который отправляет событие на сторону платформыLongPressSemanticsEvent.Код
1import 'package:flutter/material.dart';
2
3void main() {
4 runApp(const MyApp());
5}
6
7class MyApp extends StatelessWidget {
8 const MyApp({super.key});
9
10 @override
11 Widget build(BuildContext context) => MaterialApp(
12 home: Scaffold(
13 body: Center(
14 child: InkWell(
15 onLongPress: () {
16 /// В момент вызова функции появится вибрация
17 print('On long press');
18 },
19 child: Container(
20 height: 100,
21 width: 100,
22 color: Colors.green,
23 ),
24 ),
25 ),
26 ),
27 );
28}
TapSemanticEvent. Событие, которое имеет типtapи работает аналогичноLongPressSemanticsEvent, используется в методеforTap. На устройстве с включенной функцией чтения экрана после нажатия вызывает слабый толчок вибрации.
Код
1 import 'package:flutter/material.dart';
2
3 void main() {
4 runApp(const MyApp());
5 }
6
7 class MyApp extends StatelessWidget {
8 const MyApp({super.key});
9
10 @override
11 Widget build(BuildContext context) => MaterialApp(
12 home: Scaffold(
13 body: Center(
14 child: InkWell(
15 onTap: () {
16 /// В момент вызова функции появится вибрация
17 print('On tap');
18 },
19 child: Container(
20 height: 100,
21 width: 100,
22 color: Colors.green,
23 ),
24 ),
25 ),
26 ),
27 );
28 }
SemanticsNode и SemanticsConfiguration
Вместе с компоновкой виджетов создаются специальные узлы SemanticsNode, который относится к одному виджету или к группе виджетов. Каждому SemanticsNode присваивается конфигурация SemanticsConfiguration.
Конфигурация задаёт множество значений с описанием того, что делает объект, и подходит под описания всего того, что есть в интерфейсе. Например, для объекта можно задать значения того, чем является этот объект: isButton, isTextField, isLink, задать текстовое описание с помощью attributedLabel или указать кастомное поведение, выполняющееся по нажатию на виджет, с помощью onTap.
Semantics
При разработке приложения чаще всего мы используем вспомогательные объекты, которые параметры которых используются для создания SemanticsNode и SemanticsConfiguration. Одним из таких объектов является Semantics, описательный виджет, который содержит большое количество параметров. Как и SemanticsConfiguration, этот виджет может описать все, что может показываться пользователю.
Вернёмся к примеру с шаблоном и вспомним, что даже без дополнительной поддержки доступности, VoiceOver смог прочитать заголовок приложения, который находился в виджете AppBar, текст в виджете Text, кнопку и её действие в виджете FloatingActionButton, которая использовала Icon для отображения иконки со знаком плюса. Если посмотреть на исходный код виджетов или указанных в них дочерних виджетов, то можно заметить использование виджета Semantics. Рассмотрим для примера построения заголовка AppBar.
Код
1Widget? title = widget.title;
2if (title != null) {
3 bool? namesRoute;
4 switch (theme.platform) {
5 case TargetPlatform.android:
6 case TargetPlatform.fuchsia:
7 case TargetPlatform.linux:
8 case TargetPlatform.windows:
9 namesRoute = true;
10 break;
11 case TargetPlatform.iOS:
12 case TargetPlatform.macOS:
13 break;
14 }
15}
16
17title = _AppBarTitleBox(child: title);
18if (!widget.excludeHeaderSemantics) {
19 title = Semantics(
20 namesRoute: namesRoute,
21 header: true,
22 child: title,
23 );
24}
25
26title = DefaultTextStyle(
27 style: titleTextStyle!,
28 softWrap: false,
29 overflow: TextOverflow.ellipsis,
30 child: title,
31);
Тут используется Semantics с header: true, VoiceOver прочитал title AppBar как «Заголовок <текст заголовка>».
Чтобы познакомиться с виджетом Semantics, попробуем сделать кнопку с достаточно подробным описанием для технологии чтения экрана. Первая версия будет такая:
Код
1class MyCustomButton extends StatelessWidget {
2 const MyCustomButton({super.key});
3
4 @override
5 Widget build(BuildContext context) {
6 return GestureDetector(
7 onTap: () {
8 print('On tap');
9 },
10 child: Container(
11 decoration: BoxDecoration(
12 color: Colors.yellow,
13 borderRadius: BorderRadius.circular(16),
14 ),
15 padding: const EdgeInsets.all(16),
16 child: const Text('Нажми на меня'),
17 ),
18 );
19 }
20}
VoiceOver или TalkBack прочитает этот элемент как «Нажми на меня». Согласитесь, не очень говорящее описание. Обновим наш код.
Еще код
1class MyCustomButton extends StatelessWidget {
2 const MyCustomButton({super.key});
3
4 @override
5 Widget build(BuildContext context) {
6 return **Semantics(
7 label: 'Желтая кнопка с текстом Нажми на меня',
8 button: true,
9 onTap: () => ScaffoldMessenger.of(context).showSnackBar(
10 const SnackBar(content: Text('Кнопка нажата!')),
11 ),**
12 child: GestureDetector(
13 onTap: () {
14 print('On tap');
15 },
16 child: Container(
17 decoration: BoxDecoration(
18 color: Colors.yellow,
19 borderRadius: BorderRadius.circular(16),
20 ),
21 padding: const EdgeInsets.all(16),
22 child: const Text('Нажми на меня'),
23 ),
24 ),
25 );
26 }
27}
Сравним поведение до и после добавления Semantics. Вот пример без :
А вот с:
BlockSemantics, MergeSemantics, ExcludeSemantics
Часто элементы интерфейса сложнее и состоят из нескольких элементов.
1class MyCustomCard extends StatelessWidget {
2 static const _cardTitle = 'Сова';
3 static const _cardSubtitle = 'Что мы знаем о совах? Они являются символом игры «Что? Где? Когда?», в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах.';
4 static const _cardImage =
5 'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg';
6
7 const MyCustomCard({super.key});
8
9 @override
10 Widget build(BuildContext context) {
11 return GestureDetector(
12 onTap: () {
13 print('On tap');
14 },
15 child: Container(
16 decoration: BoxDecoration(
17 color: Colors.yellow,
18 borderRadius: BorderRadius.circular(16),
19 ),
20 padding: const EdgeInsets.all(16),
21 child: Column(
22 mainAxisSize: MainAxisSize.min,
23 children: [
24 const Text(
25 _cardTitle,
26 style: TextStyle(fontSize: 25, fontWeight: FontWeight.w500),
27 ),
28 const Padding(
29 padding: EdgeInsets.only(top: 8),
30 child: Text(
31 _cardSubtitle,
32 textAlign: TextAlign.center,
33 ),
34 ),
35 Image.network(_cardImage),
36 ],
37 ),
38 ),
39 );
40 }
41}
{% endcut %}
Вот такой интерфейс получился:
Для технологии чтения экрана такой виджет будет читаться как «Сова, изображение, Что мы знаем о совах? Они являются символом игры “Что? Где? Когда?”, в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах».
Изображение выглядит лишним, а заголовок в целом можно и не читать, оно скорее сделано для красоты. Существует еще и другая проблема — VoiceOver считает, что на экране находится не один элемент, а несколько разных элементов.
Поправим ситуацию
1class MyCustomCard extends StatelessWidget {
2 static const _cardTitle = 'Сова';
3 static const _cardSubtitle = 'Что мы знаем о совах? Они являются символом игры «Что? Где? Когда?», в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах.';
4 static const _cardImage =
5 'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg';\
6
7 const MyCustomCard({super.key});
8
9 @override
10 Widget build(BuildContext context) {
11 return **MergeSemantics**(
12 child: GestureDetector(
13 onTap: () {
14 print('On tap');
15 },
16 child: Container(
17 decoration: BoxDecoration(
18 color: Colors.yellow,
19 borderRadius: BorderRadius.circular(16),
20 ),
21 padding: const EdgeInsets.all(16),
22 child: Column(
23 mainAxisSize: MainAxisSize.min,
24 children: [
25 const Text(
26 _cardTitle,
27 style: TextStyle(fontSize: 25, fontWeight: FontWeight.w500),
28 ),
29 const **BlockSemantics**(
30 child: Padding(
31 padding: EdgeInsets.only(top: 8),
32 child: Text(
33 _cardSubtitle,
34 textAlign: TextAlign.center,
35 ),
36 ),
37 ),
38 **ExcludeSemantics**(
39 child: Image.network(_cardImage),
40 ),
41 ],
42 ),
43 ),
44 ),
45 );
46 }
47}
Мы добавили MergeSemantics как корень виджета. Теперь все виджеты, относящиеся к semantics, будут объединены в один SemanticsNode, а читаться они будут как одно целое.
BlockSemantics блокирует все виджеты выше себя в рамках своей SemanticsNode. В текущем примере SemanticsNode ограничивается MergeSemantics. BlockSemantics делает их невидимыми для VoiceOver и Talkback, так что благодаря нему заголовок карточки "Сова" перестал читаться, ведь Text с текстом _cardTitle выше, чем BlockSemantics.
ExcludeSemantics сделал изображение невидимым для VoiceOver - теперь оно не будет озвучиваться. В отличие от BlockSemantics, ExcludeSemantics блокирует для чтения только один виджет - тот, который указан как child.
Semantics в debug
Тестировать, все ли виджеты имеют достаточно описания при включенном чтении экрана, достаточно сложно. Эту ситуацию немного исправляет showSemanticsDebugger, флаг в MaterialApp, CupertionApp и WidgetApp. Работает таким образом, что вместо виджета разработчик видит его текстовое описание, то, что будет прочитано пользователю, а также границы виджета. Например, наш финальный виджет MyCustomCard будет выглядеть таким образом:
Accessibility — важная функциональность, которой не стоит пренебрегать. Мы всегда должны принимать это во внимание, обеспечивая доступность приложений и делая их доступными для всех, кто пользуется смартфоном.
Команда Flutter поддержала accessibility-функционал, и зачастую он уже включен во многие виджеты из коробки. Однако для поддержки доступности в кастомных виджетах достаточно обернуть их в Semantics и поддержать правильную группировку, что не займёт много времени.
А если кажется, что ваш случай совсем сложный и приложение невозможно сделать на 100% доступным, или просто хочется вдохновиться, то вот пара статей:
- Про невидимые элементы в навигационных приложениях Яндекса
- И про нестандартные элементы в Яндекс Go
И напоследок — интересное видео, которое помогает лучше понять, как взаимодействуют с доступностью пользователи:
