Введение

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

Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности (англ. 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. Он сопоставляет фон и цвет текста и оценивает контрастность интерфейса по десятибалльной шкале.

Screenshot

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

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

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

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

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}

Рассмотрим имеющиеся во фреймворке реализации для различных событий

  • 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. На устройстве с включенной функцией чтения экрана после нажатия вызывает слабый толчок вибрации.

    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

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}

Вот такой интерфейс получился:

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

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

Semantics в debug

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

Group

Заключение

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

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

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

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

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

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

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