Введение

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

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

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

Специальные возможности в мобильном приложении

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

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

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

В коде можно получить значение изменения шрифта через MediaQuery: MediaQuery.of(context).textScaleFactor.

Стандартное значение для textScaleFactor без дополнительных настроек равно 1,0. На скринах ниже оно эквивалентно 0,88 и 3,12 соответственно.

textScaleFactor 0,88 (слева)
textScaleFactor 3,12 (справа)

Можно заметить, что на втором скриншоте размер текста аж в три раза больше, чем стандартный. Очень немногие приложения готовы поддерживать настолько большие шрифты, так что приходится ставить ограничения. Вспомним, что MediaQuery — это InheritedWidget, а значит, мы сможем указать свои параметры:

@override
  Widget build(BuildContext context) => MaterialApp(
        builder: (context, _) {
          final mediaQueryData = MediaQuery.of(context);
          return MediaQuery(
            data: mediaQueryData.copyWith(
							// Переопределяем значение textScaleFactor 
              textScaleFactor: min(mediaQueryData.textScaleFactor, 1.5),
            ),
            child: const HomePage(),
          );
        },
      );

Таким образом мы переопределили поведение: указали, что максимальный textScaleFactor, который мы готовы поддерживать, равен 1,5.

💡 Учтите, что при указании fontSize в TextStyle в таких виджетах, как, например, Text, textScaleFactor также будет применяться, так что конечный размер текста будет на самом деле равен произведению fontSize и textScaleFactor.

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

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

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

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

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

Повышает контрастность между цветами переднего и заднего планов приложения. Эту настройку можно получить из MediaQuery: MediaQuery.of(context).invertColors и переопределить через InheritedWidget:

@override
Widget build(BuildContext context) => MaterialApp(
      builder: (context, _) {
        final mediaQueryData = MediaQuery.of(context);
        return MediaQuery(
          data: mediaQueryData.copyWith(
            invertColors: false, // Переопределяем значение invertColors
          ),
          child: const HomePage(),
        );
      },
    );

Флаг контрастности invertColors в данный момент поддержан только на iOS.

Жирный шрифт

Делает все шрифты устройства жирными. Эту настройку можно получить из MediaQuery: MediaQuery.of(context).boldText и аналогично переопределить через InheritedWidget:

@override
  Widget build(BuildContext context) => MaterialApp(
        builder: (context, _) {
          final mediaQueryData = MediaQuery.of(context);
          return MediaQuery(
            data: mediaQueryData.copyWith(
              boldText: false, // Переопределяем значение boldText
            ),
            child: const HomePage(),
          );
        },
      );

⚠️ Как и с 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.

    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,
                  ),
                ),
              ),
            ),
          );
    }
    

    Подобный код приведет к тому, что по тапу на кнопку будет произнесено сообщение "My message".

  • Метод tooltip, который позволяет отправить семантическое оповещение для тултипов

    Поддерживается только Android платформой и вызывается в коде фреймворка только один раз — после показа виджета Tooltip. Выглядит как всплывающая системная подсказка, позволяющая уведомить пользователя.

    💡 SemanticsService используется в достаточно специфичных случаях. Большая часть объявлений об изменении пользовательского интерфейса и нажатиях покрывается уже имеющимся возможностями устройства. Однако бывают крайние кейсы, которые нужно дополнительно озвучивать. Хорошим примером является изменение фокуса в приложении камеры. Если включен VoiceOver и открыто приложение камеры, то каждое изменение фокуса будет сопровождаться голосом.

SemanticsEvent

SemanticsEvent

События, которые отправляются приложением и обозначающие действия, которые делает пользователь. Используется для отправки в нативную часть приложения на стороне движка. Каждый Event содержит поле type, который можно считать аналогом UIAccessibility Notification на iOS и AccessibilityEvent на Android.

/// Абстрактный класс, который реализуют все события.
abstract class SemanticsEvent {
  const SemanticsEvent(this.type);
  
  /// Тип события, аналог UIAccessibility Notification/AccessibilityEvent
  final String type;

  /// Преобразует событие в Map, которая может быть закодирована с помощью
  /// [StandardMessageCodec].
  Map<String, dynamic> toMap({ int? nodeId }) {
    final Map<String, dynamic> event = <String, dynamic>{
      'type': type,
      'data': getDataMap(),
    };
    if (nodeId != null) {
      event['nodeId'] = nodeId;
    }

    return event;
  }

  /// Преобразует данные события в Map, используется методом toMap.
  Map<String, dynamic> getDataMap();
}

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

  • 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.

    import 'package:flutter/material.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: InkWell(
                  onLongPress: () {
                    /// В момент вызова функции появится вибрация
                    print('On long press');
                  },
                  child: Container(
                    height: 100,
                    width: 100,
                    color: Colors.green,
                  ),
                ),
              ),
            ),
          );
    }
    
  • TapSemanticEvent

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

    import 'package:flutter/material.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: InkWell(
                  onTap: () {
                    /// В момент вызова функции появится вибрация
                    print('On tap');
                  },
                  child: Container(
                    height: 100,
                    width: 100,
                    color: Colors.green,
                  ),
                ),
              ),
            ),
          );
    }
    
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:

Widget? title = widget.title;
if (title != null) {
  bool? namesRoute;
  switch (theme.platform) {
    case TargetPlatform.android:
    case TargetPlatform.fuchsia:
    case TargetPlatform.linux:
    case TargetPlatform.windows:
      namesRoute = true;
      break;
    case TargetPlatform.iOS:
    case TargetPlatform.macOS:
      break;
  }
}

title = _AppBarTitleBox(child: title);
if (!widget.excludeHeaderSemantics) {
  title = Semantics(
    namesRoute: namesRoute,
    header: true,
    child: title,
  );
}

title = DefaultTextStyle(
  style: titleTextStyle!,
  softWrap: false,
  overflow: TextOverflow.ellipsis,
  child: title,
);

Тут используется Semantics с header: true, VoiceOver прочитал title AppBar как «Заголовок <текст заголовка>».

Чтобы познакомиться с виджетом Semantics, попробуем сделать кнопку с достаточно подробным описанием для технологии чтения экрана. Первая версия будет такая:

class MyCustomButton extends StatelessWidget {
  const MyCustomButton({super.key});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        print('On tap');
      },
      child: Container(
        decoration: BoxDecoration(
          color: Colors.yellow,
          borderRadius: BorderRadius.circular(16),
        ),
        padding: const EdgeInsets.all(16),
        child: const Text('Нажми на меня'),
      ),
    );
  }
}

VoiceOver или TalkBack прочитает этот элемент как «Нажми на меня». Согласитесь, не очень говорящее описание. Обновим наш код таким образом:

class MyCustomButton extends StatelessWidget {
  const MyCustomButton({super.key});

  @override
  Widget build(BuildContext context) {
    return **Semantics(
      label: 'Желтая кнопка с текстом Нажми на меня',
      button: true,
      onTap: () => ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Кнопка нажата!')),
      ),**
      child: GestureDetector(
        onTap: () {
          print('On tap');
        },
        child: Container(
          decoration: BoxDecoration(
            color: Colors.yellow,
            borderRadius: BorderRadius.circular(16),
          ),
          padding: const EdgeInsets.all(16),
          child: const Text('Нажми на меня'),
        ),
      ),
    );
  }
}

💬 Для использования локализированной строки в label достаточно использовать строки из AppLocalizations по аналогии с использованием их в виджетах. Если текст для label содержится в поле yellowButtonLabel, то в label достаточно указать следующее:

label: AppLocalizations.of(context).yellowButtonLabel

Подробнее про локализацию разговаривали в главе Интернационализация.

Сравним поведение до и после добавления Semantics:

Без Semantics

С добавлением Semantics

BlockSemantics, MergeSemantics, ExcludeSemantics

BlockSemantics, MergeSemantics, ExcludeSemantics

Часто элементы интерфейса сложнее и состоят из нескольких элементов. Рассмотрим такой пример:

class MyCustomCard extends StatelessWidget {
  static const _cardTitle = 'Сова';
  static const _cardSubtitle = 'Что мы знаем о совах? Они являются символом игры «Что? Где? Когда?», в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах.';
  static const _cardImage =
      'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg';

  const MyCustomCard({super.key});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        print('On tap');
      },
      child: Container(
        decoration: BoxDecoration(
          color: Colors.yellow,
          borderRadius: BorderRadius.circular(16),
        ),
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text(
              _cardTitle,
              style: TextStyle(fontSize: 25, fontWeight: FontWeight.w500),
            ),
            const Padding(
              padding: EdgeInsets.only(top: 8),
              child: Text(
                _cardSubtitle,
                textAlign: TextAlign.center,
              ),
            ),
            Image.network(_cardImage),
          ],
        ),
      ),
    );
  }
}

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

Group

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

Изображение выглядит лишним, а заголовок в целом можно и не читать, оно скорее сделано для красоты. Существует еще и другая проблема — VoiceOver считает, что на экране находится не один элемент, а несколько разных элементов. Поправим ситуацию таким образом:

class MyCustomCard extends StatelessWidget {
  static const _cardTitle = 'Сова';
  static const _cardSubtitle = 'Что мы знаем о совах? Они являются символом игры «Что? Где? Когда?», в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах.';
  static const _cardImage =
      'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg';\

  const MyCustomCard({super.key});

  @override
  Widget build(BuildContext context) {
    return **MergeSemantics**(
      child: GestureDetector(
        onTap: () {
          print('On tap');
        },
        child: Container(
          decoration: BoxDecoration(
            color: Colors.yellow,
            borderRadius: BorderRadius.circular(16),
          ),
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Text(
                _cardTitle,
                style: TextStyle(fontSize: 25, fontWeight: FontWeight.w500),
              ),
              const **BlockSemantics**(
                child: Padding(
                  padding: EdgeInsets.only(top: 8),
                  child: Text(
                    _cardSubtitle,
                    textAlign: TextAlign.center,
                  ),
                ),
              ),
              **ExcludeSemantics**(
                child: Image.network(_cardImage),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Мы добавили 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 + работа с файловой системой