Введение
Нередко бывает такое, что пользователи имеют какие-либо физические или возрастные ограничения, влияющие на работу с приложением. Из-за ограничений слуха будет невозможно посмотреть видео без субтитров или услышать звуковое уведомление, а из-за ограничений зрения — взаимодействовать с интерфейсом приложения.
Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности (англ. accessibility). Доступность приложения тесно связана с понятием универсального дизайна — концепцией, которая гласит, что продукт должен быть доступен для всех вне зависимости от возраста, возможностей здоровья и других факторов.
В этой статье мы рассмотрим популярные практики и инструменты во Flutter, с помощью которых приложение можно сделать максимально доступным.
Специальные возможности в мобильном приложении
Перед переходом к разработке функциональности стоит задуматься, а как пользователь собирается пользоваться продуктом? Если большей части аудитории будет хватать хорошего UX, то пользователи с ограничениями требуют больше внимания. В этой части статьи рассмотрим, что делают пользователи для комфортной работы с мобильным устройством и как это поддерживается в мобильном приложении.
Измененный размер шрифта
Изменением размера шрифта часто пользуются пожилые и люди с проблемами зрения. Это системная настройка, которая позволяет как увеличивать размер текста, так и уменьшать его.
В коде можно получить значение изменения шрифта через 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
💡 Все примеры кода, который представлен ниже, следует запускать на реальном устройстве с включённым VoiceOver и Talkback. На эмуляторе технология доступности работать не будет.
В части фреймворка semantics содержится функциональность для работы с доступностью приложения. Большая часть классов и методов размещена во flutter/lib/src/semantics/
и объединена экспортом package:flutter/semantics.dart
. Рассмотрим, что он в себя включает:
semanticsBinding
«Клей» между движком и слоем семантики. Подробнее было ранее рассказано в статье Bindings.
SemanticsService
Специфичный для платформы код, который работает с платформенными реализациями функциональности доступности:
-
Метод
announce
, который позволяет отправить семантическое оповещениеРассмотрим простой пример работы функции
announce
.1import 'package:flutter/material.dart'; 2import 'package:flutter/semantics.dart'; 3 4void main() { 5 runApp(const MyApp()); 6} 7 8class MyApp extends StatelessWidget { 9 const MyApp({super.key}); 10 11 @override 12 Widget build(BuildContext context) => MaterialApp( 13 home: Scaffold( 14 body: Center( 15 child: OutlinedButton( 16 child: const Text('Announce message'), 17 onPressed: () => SemanticsService.announce( 18 'My message', 19 TextDirection.ltr, 20 ), 21 ), 22 ), 23 ), 24 ); 25}
Подобный код приведет к тому, что по тапу на кнопку будет произнесено сообщение "My message".
-
Метод
tooltip
, который позволяет отправить семантическое оповещение для тултиповПоддерживается только Android платформой и вызывается в коде фреймворка только один раз — после показа виджета
Tooltip
. Выглядит как всплывающая системная подсказка, позволяющая уведомить пользователя.💡
SemanticsService
используется в достаточно специфичных случаях. Большая часть объявлений об изменении пользовательского интерфейса и нажатиях покрывается уже имеющимся возможностями устройства. Однако бывают крайние кейсы, которые нужно дополнительно озвучивать. Хорошим примером является изменение фокуса в приложении камеры. Если включен VoiceOver и открыто приложение камеры, то каждое изменение фокуса будет сопровождаться голосом.
SemanticsEvent
События, которые отправляются приложением и обозначающие действия, которые делает пользователь. Используется для отправки в нативную часть приложения на стороне движка. Каждый Event содержит поле type, который можно считать аналогом UIAccessibility Notification
на iOS и AccessibilityEvent
на Android.
1/// Абстрактный класс, который реализуют все события.
2abstract class SemanticsEvent {
3 const SemanticsEvent(this.type);
4
5 /// Тип события, аналог UIAccessibility Notification/AccessibilityEvent
6 final String type;
7
8 /// Преобразует событие в Map, которая может быть закодирована с помощью
9 /// [StandardMessageCodec].
10 Map<String, dynamic> toMap({ int? nodeId }) {
11 final Map<String, dynamic> event = <String, dynamic>{
12 'type': type,
13 'data': getDataMap(),
14 };
15 if (nodeId != null) {
16 event['nodeId'] = nodeId;
17 }
18
19 return event;
20 }
21
22 /// Преобразует данные события в Map, используется методом toMap.
23 Map<String, dynamic> getDataMap();
24}
Рассмотрим имеющиеся во фреймворке реализации для различных событий
-
Событие, которое имеет тип
announce
и используетсяSemanticsService
в методеannounce
, который мы рассмотрели ранее. Этот события используется для уведомления платформы о том, что действие следует озвучит. -
Событие, которое имеет тип
tooltip
и используетсяSemanticsService
в методеtooltip
, который мы также рассмотрели ранее. Этот события используется для уведомления платформы о том, что следует показать подсказку. -
Событие, которое имеет тип
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}
-
Событие, которое имеет тип
tap
и работает аналогичноLongPressSemanticsEvent
, используется в методеforTap
. На устройстве с включенной функцией чтения экрана после нажатия вызывает слабый толчок вибрации.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 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
SemanticsNode
и SemanticsConfiguration
Вместе с компоновкой виджетов, создаются специальные узлы SemanticsNode
, который относится к одному виджету или к группе виджетов. Каждому SemanticsNode
присваивается конфигурация SemanticsConfiguration
.
Если заглянуть а документацию 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}
💬 Для использования локализированной строки в label
достаточно использовать строки из AppLocalizations
по аналогии с использованием их в виджетах. Если текст для label
содержится в поле yellowButtonLabel, то в label достаточно указать следующее:
label: AppLocalizations.of(context).yellowButtonLabel
Подробнее про локализацию разговаривали в главе Интернационализация.
Сравним поведение до и после добавления Semantics
:
Без Semantics
С добавлением Semantics
BlockSemantics
, MergeSemantics
, ExcludeSemantics
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}
Вот такой интерфейс получился:
Для технологии чтения экрана такой виджет будет читаться как «Сова, изображение, Что мы знаем о совах? Они являются символом игры “Что? Где? Когда?”, в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах».
Изображение выглядит лишним, а заголовок в целом можно и не читать, оно скорее сделано для красоты. Существует еще и другая проблема — 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% доступным, или просто хочется вдохновиться, можно почитать пару статей:
Интересное видео, которое помогает лучше понять, как взаимодействуют с доступностью пользователи: