Нередко бывает такое, что у пользователей есть какие-либо физические или возрастные ограничения, которые влияют на работу с приложением.
Из-за ограничений слуха они не смогут посмотреть видео без субтитров или услышать звуковое уведомление, а из-за ограничений зрения — взаимодействовать с интерфейсом приложения.
Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности
(англ*.* 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
.
Он включает:
semanticsBinding
SemanticsService
SemanticsEvent
SemanticsNode
,SemanticsConfiguration
Semantics
BlockSemantics
,MergeSemantics
,ExcludeSemantics
Semantics
в 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
И напоследок — интересное видео, которое помогает лучше понять, как взаимодействуют с доступностью пользователи: