Нередко бывает такое, что у пользователей есть какие-либо физические или возрастные ограничения, которые влияют на работу с приложением.

Из-за ограничений слуха они не смогут посмотреть видео без субтитров или услышать звуковое уведомление, а из-за ограничений зрения — взаимодействовать с интерфейсом приложения.

Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности (англ*.* accessibility). Доступность приложения тесно связана с понятием универсального дизайна — концепцией, которая гласит, что продукт должен быть доступен для всех вне зависимости от возраста, возможностей здоровья и других факторов.

В этой статье мы рассмотрим популярные практики и инструменты во Flutter, с помощью которых приложение можно сделать максимально доступным.

Измененный размер шрифта

Изменением размера шрифта часто пользуются пожилые и люди с проблемами зрения. Это системная настройка, которая позволяет как увеличивать размер текста, так и уменьшать его.

В коде можно получить значение изменения шрифта через MediaQueryMediaQuery.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 в таких виджетах, как, например, TexttextScaleFactor также будет применяться, так что конечный размер текста будет на самом деле равен произведению fontSize и textScaleFactor.

Важно! Стоит аккуратнее переопределять textScaleFactor. Если указать переопределение textScaleFactor: 1 (запретить изменение шрифта), то пользователям с нарушениями зрения будет некомфортно, и они, вероятно, удалят приложение.

Контрастность

При проблемах с цветоощущением, дальтонизме или слабом зрении бывает сложно прочитать текст на ярком фоне или бледный текст. Есть общее правило, которое гласит, что при проектировании интерфейса стоит ориентироваться не на цвет, а на то, насколько контрастны цвета, в большей степени это касается читаемости текста.

В настройках специальных возможностей также есть два пункта, которые помогают решить эту проблему:

Увеличение контрастности

Повышает контрастность между цветами переднего и заднего планов приложения. Эту настройку можно получить из MediaQueryMediaQuery.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.

Жирный шрифт

Делает все шрифты устройства жирными. Эту настройку можно получить из MediaQueryMediaQuery.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. Он сопоставляет фон и цвет текста и оценивает контрастность интерфейса по десятибалльной шкале.

Screenshot

Поддержка чтения экрана

Чтение экрана — функция, которая помогает использовать мобильное приложение людям с ослабленным зрением.

Для этого на 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

Специфичный для платформы код, который работает с платформенными реализациями функциональности доступности. Содержит два метода — 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 %}
Вот такой интерфейс получился:

Group

Для технологии чтения экрана такой виджет будет читаться как «Сова, изображение, Что мы знаем о совах? Они являются символом игры “Что? Где? Когда?”, в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах».

Изображение выглядит лишним, а заголовок в целом можно и не читать, оно скорее сделано для красоты. Существует еще и другая проблема — 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 ограничивается MergeSemanticsBlockSemantics делает их невидимыми для VoiceOver и Talkback, так что благодаря нему заголовок карточки "Сова" перестал читаться, ведь Text с текстом _cardTitle выше, чем BlockSemantics.

ExcludeSemantics сделал изображение невидимым для VoiceOver - теперь оно не будет озвучиваться. В отличие от BlockSemanticsExcludeSemantics блокирует для чтения только один виджет - тот, который указан как child.

Semantics в debug

Тестировать, все ли виджеты имеют достаточно описания при включенном чтении экрана, достаточно сложно. Эту ситуацию немного исправляет showSemanticsDebugger, флаг в MaterialApp, CupertionApp и WidgetApp. Работает таким образом, что вместо виджета разработчик видит его текстовое описание, то, что будет прочитано пользователю, а также границы виджета. Например, наш финальный виджет MyCustomCard будет выглядеть таким образом:

Group

Accessibility — важная функциональность, которой не стоит пренебрегать. Мы всегда должны принимать это во внимание, обеспечивая доступность приложений и делая их доступными для всех, кто пользуется смартфоном.

Команда Flutter поддержала accessibility-функционал, и зачастую он уже включен во многие виджеты из коробки. Однако для поддержки доступности в кастомных виджетах достаточно обернуть их в Semantics и поддержать правильную группировку, что не займёт много времени.

А если кажется, что ваш случай совсем сложный и приложение невозможно сделать на 100% доступным, или просто хочется вдохновиться, то вот пара статей:

И напоследок — интересное видео, которое помогает лучше понять, как взаимодействуют с доступностью пользователи:

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.16. Слои и PaintingContext
Следующий параграф4.1. Разные пакеты persistence + работа с файловой системой