Введение
Нередко бывает такое, что пользователи имеют какие-либо физические или возрастные ограничения, влияющие на работу с приложением. Из-за ограничений слуха будет невозможно посмотреть видео без субтитров или услышать звуковое уведомление, а из-за ограничений зрения — взаимодействовать с интерфейсом приложения.
Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности (англ. accessibility). Доступность приложения тесно связана с понятием универсального дизайна — концепцией, которая гласит, что продукт должен быть доступен для всех вне зависимости от возраста, возможностей здоровья и других факторов.
В этой статье мы рассмотрим популярные практики и инструменты во Flutter, с помощью которых приложение можно сделать максимально доступным.
Специальные возможности в мобильном приложении
Перед переходом к разработке функциональности стоит задуматься, а как пользователь собирается пользоваться продуктом? Если большей части аудитории будет хватать хорошего UX, то пользователи с ограничениями требуют больше внимания. В этой части статьи рассмотрим, что делают пользователи для комфортной работы с мобильным устройством и как это поддерживается в мобильном приложении.
Измененный размер шрифта
Изменением размера шрифта часто пользуются пожилые и люди с проблемами зрения. Это системная настройка, которая позволяет как увеличивать размер текста, так и уменьшать его.
В коде можно получить значение изменения шрифта через MediaQuery
: MediaQuery.of(context).textScaleFactor
.
Стандартное значение для textScaleFactor
без дополнительных настроек равно 1,0. На скринах ниже оно эквивалентно 0,88 и 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, то оказывается, что все элементы интерфейса поддерживают функциональность чтения экрана, хотя дополнительные доработки для этого не делались.
Большинство виджетов фреймворка поддерживают необходимое описание для работы функций чтения экрана. Это может быть как обычный текст с дополнительной информацией, так и описание элемента и действия, которое выполняется при взаимодействии с этим элементом. Чуть ниже мы рассмотрим примеры реализаций из самого фреймворка.
Semantics
💡 Все примеры кода, который представлен ниже, следует запускать на реальном устройстве с включённым VoiceOver и Talkback. На эмуляторе технология доступности работать не будет.
В части фреймворка semantics содержится функциональность для работы с доступностью приложения. Большая часть классов и методов размещена во flutter/lib/src/semantics/
и объединена экспортом package:flutter/semantics.dart
. Рассмотрим, что он в себя включает:
semanticsBinding
«Клей» между движком и слоем семантики. Подробнее было ранее рассказано в статье Bindings.
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
События, которые отправляются приложением и обозначающие действия, которые делает пользователь. Используется для отправки в нативную часть приложения на стороне движка. Каждый 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();
}
Рассмотрим имеющиеся во фреймворке реализации для различных событий
-
Событие, которое имеет тип
announce
и используетсяSemanticsService
в методеannounce
, который мы рассмотрели ранее. Этот события используется для уведомления платформы о том, что действие следует озвучит. -
Событие, которое имеет тип
tooltip
и используетсяSemanticsService
в методеtooltip
, который мы также рассмотрели ранее. Этот события используется для уведомления платформы о том, что следует показать подсказку. -
Событие, которое имеет тип
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, ), ), ), ), ); }
-
Событие, которое имеет тип
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
При разработке приложения чаще всего мы используем вспомогательные объекты, которые параметры которых используются для создания 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),
],
),
),
);
}
}
Вот такой интерфейс получился:
Для технологии чтения экрана такой виджет будет читаться как «Сова, изображение, Что мы знаем о совах? Они являются символом игры “Что? Где? Когда?”, в Греции сова символизирует мудрость и учёность, её изображение можно встретить на монетах Древней Греции и на более современных металлических кругляшах».
Изображение выглядит лишним, а заголовок в целом можно и не читать, оно скорее сделано для красоты. Существует еще и другая проблема — 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% доступным, или просто хочется вдохновиться, можно почитать пару статей:
Интересное видео, которое помогает лучше понять, как взаимодействуют с доступностью пользователи: