Введение
Нередко бывает такое, что пользователи имеют какие-либо физические или возрастные ограничения, влияющие на работу с приложением. Из-за ограничений слуха будет невозможно посмотреть видео без субтитров или услышать звуковое уведомление, а из-за ограничений зрения — взаимодействовать с интерфейсом приложения.
Чтобы все пользователи чувствовали себя комфортно, а продукт не терял аудиторию, существует понятие доступности (англ. 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, то оказывается, что все элементы интерфейса поддерживают функциональность чтения экрана, хотя дополнительные доработки для этого не делались.
Пример работы VoiceOver с шаблоном
Большинство виджетов фреймворка поддерживают необходимое описание для работы функций чтения экрана. Это может быть как обычный текст с дополнительной информацией, так и описание элемента и действия, которое выполняется при взаимодействии с этим элементом. Чуть ниже мы рассмотрим примеры реализаций из самого фреймворка.
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% доступным, или просто хочется вдохновиться, можно почитать пару статей:
Интересное видео, которое помогает лучше понять, как взаимодействуют с доступностью пользователи: