3.17. Доступность
Автор: Софья Тимохина
Нередко бывает такое, что у пользователей есть какие-либо физические или возрастные ограничения, которые влияют на работу с приложением.
Из-за ограничений слуха они не смогут посмотреть видео без субтитров или услышать звуковое уведомление, а из-за ограничений зрения — взаимодействовать с интерфейсом приложения.
Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности (англ*.* accessibility). Доступность приложения тесно связана с понятием универсального дизайна — концепцией, которая гласит, что продукт должен быть доступен для всех вне зависимости от возраста, возможностей здоровья и других факторов.
В этой статье мы рассмотрим популярные практики и инструменты во Flutter, с помощью которых приложение можно сделать максимально доступным.
Измененный размер шрифта
Изменением размера шрифта часто пользуются пожилые и люди с проблемами зрения. Это системная настройка, которая позволяет как увеличивать размер текста, так и уменьшать его.
В коде можно получить значение изменения шрифта через 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. Он сопоставляет фон и цвет текста и оценивает контрастность интерфейса по десятибалльной шкале.
Поддержка чтения экрана
Чтение экрана — функция, которая помогает использовать мобильное приложение людям с ослабленным зрением.
Для этого на 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.
Он включает:
semanticsBindingSemanticsServiceSemanticsEventSemanticsNode,SemanticsConfigurationSemanticsBlockSemantics,MergeSemantics,ExcludeSemanticsSemanticsв debug.
О SemanticsBinding мы уже рассказывали в статье «Сервисы связи», останавливаться на нём тут не будем — скажем только, что это «клей» между движком и слоем семантики.
А остальные рассмотрим подробнее.
SemanticsService
Специфичный для платформы код, который работает с платформенными реализациями функциональности доступности. Содержит два метода — announce() и tooltip().
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,
),
),
),
),
);
}
tooltipпозволяет отправить семантическое оповещение для тултипов.
Поддерживается только Android платформой и вызывается в коде фреймворка только один раз — после показа виджета Tooltip. Выглядит как всплывающая системная подсказка, позволяющая уведомить пользователя.
Примечание
SemanticsService используется в достаточно специфичных случаях. Большая часть объявлений об изменении пользовательского интерфейса и нажатиях покрывается уже имеющимся возможностями устройства.
Однако бывают крайние кейсы, которые нужно дополнительно озвучивать. Хорошим примером является изменение фокуса в приложении камеры. Если включен VoiceOver и открыто приложение камеры, то каждое изменение фокуса будет сопровождаться голосом.
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 и SemanticsConfiguration
Вместе с компоновкой виджетов создаются специальные узлы SemanticsNode, который относится к одному виджету или к группе виджетов. Каждому SemanticsNode присваивается конфигурация SemanticsConfiguration.
Конфигурация задаёт множество значений с описанием того, что делает объект, и подходит под описания всего того, что есть в интерфейсе. Например, для объекта можно задать значения того, чем является этот объект: isButton, isTextField, isLink, задать текстовое описание с помощью attributedLabel или указать кастомное поведение, выполняющееся по нажатию на виджет, с помощью onTap.
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('Нажми на меня'),
),
),
);
}
}
Сравним поведение до и после добавления Semantics. Вот пример без :
А вот с:
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),
],
),
),
);
}
}
Вот такой интерфейс получился:
Для технологии чтения экрана такой виджет будет читаться как «Сова, изображение, Что мы знаем о совах? Они являются символом игры “Что? Где? Когда?”, в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах».
Изображение выглядит лишним, а заголовок в целом можно и не читать, оно скорее сделано для красоты. Существует еще и другая проблема — 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 будет выглядеть таким образом:
Accessibility — важная функциональность, которой не стоит пренебрегать. Мы всегда должны принимать это во внимание, обеспечивая доступность приложений и делая их доступными для всех, кто пользуется смартфоном.
Команда Flutter поддержала accessibility-функционал, и зачастую он уже включен во многие виджеты из коробки. Однако для поддержки доступности в кастомных виджетах достаточно обернуть их в Semantics и поддержать правильную группировку, что не займёт много времени.
А если кажется, что ваш случай совсем сложный и приложение невозможно сделать на 100% доступным, или просто хочется вдохновиться, то вот пара статей:
- Про невидимые элементы в навигационных приложениях Яндекса
- И про нестандартные элементы в Яндекс Go
И напоследок — интересное видео, которое помогает лучше понять, как взаимодействуют с доступностью пользователи: