Как Flutter реализует функции доступности (accessibility)?
Flutter строит параллельное дерево SemanticsNode поверх widget-дерева и транслирует его в нативный API доступности платформы (UIAccessibility/AccessibilityNodeInfo); кастомные виджеты требуют явного обёртывания в Semantics с label, hint и действиями.
Как Flutter реализует доступность
Flutter рисует весь UI через собственный движок (Skia/Impeller) и не использует нативные виджеты платформы. Это означает, что операционная система не знает ничего о кнопках, текстовых полях и изображениях внутри Flutter-приложения. Чтобы TalkBack (Android) и VoiceOver (iOS) могли читать экран, Flutter строит отдельное дерево семантики — SemanticsTree — параллельно дереву виджетов.
SemanticsNode и Semantics-виджет
Каждый виджет, который хочет быть виден скринридеру, создаёт SemanticsNode. Низкоуровневые виджеты Flutter (Text, ElevatedButton, Checkbox и другие) уже содержат встроенные аннотации. Для кастомных виджетов используется обёртка Semantics:
Semantics(
label: 'Удалить задачу',
hint: 'Двойное нажатие удаляет задачу из списка',
button: true,
enabled: !isLoading,
child: GestureDetector(
onTap: onDelete,
child: Icon(Icons.delete, color: Colors.red),
),
)
Параметры label, hint, value, button, checked, enabled, onTap, onLongPress транслируются во флаги и строки, понятные платформенному API доступности.
Слияние семантики: MergeSemantics и ExcludeSemantics
Составные виджеты часто содержат несколько дочерних узлов, каждый из которых генерирует свой SemanticsNode. Скринридер тогда будет останавливаться на каждом элементе по отдельности. Чтобы сгруппировать их в один фокус доступности, используется MergeSemantics:
MergeSemantics(
child: Row(
children: [
Icon(Icons.star, color: Colors.amber),
SizedBox(width: 4),
Text('4.8'),
Text(' (120 отзывов)'),
],
),
)
TalkBack объявит это как единый элемент: «4.8 (120 отзывов)». Чтобы скрыть декоративный элемент от скринридера, используется ExcludeSemantics:
ExcludeSemantics(
child: Icon(Icons.decorative_divider),
)
Мост к платформе: SemanticsBinding и AccessibilityBridge
Flutter Engine содержит AccessibilityBridge (реализован отдельно для Android и iOS). Когда TalkBack или VoiceOver активируется, Engine получает системное событие через SemanticsBinding.ensureSemantics(), после чего начинает синхронизировать SemanticsTree с нативным деревом доступности (UIAccessibility на iOS, AccessibilityNodeInfo на Android). Обновление дерева происходит каждый кадр при наличии изменений.
Практический пример: кастомный SliderWidget
class AccessibleSlider extends StatelessWidget {
final double value;
final double min;
final double max;
final ValueChanged<double> onChanged;
const AccessibleSlider({
super.key,
required this.value,
required this.min,
required this.max,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Semantics(
slider: true,
value: '${value.toStringAsFixed(0)}',
increasedValue: '${(value + 1).clamp(min, max).toStringAsFixed(0)}',
decreasedValue: '${(value - 1).clamp(min, max).toStringAsFixed(0)}',
onIncrease: () => onChanged((value + 1).clamp(min, max)),
onDecrease: () => onChanged((value - 1).clamp(min, max)),
child: CustomPaint(
painter: SliderPainter(value: value, min: min, max: max),
size: const Size(double.infinity, 40),
),
);
}
}
Параметры increasedValue и decreasedValue позволяют TalkBack заранее объявить значение до того, как пользователь сделает свайп.
Тестирование доступности
Flutter предоставляет SemanticsController в widget-тестах:
testWidgets('delete button has correct semantics', (tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(MaterialApp(home: TaskItem(onDelete: () {})));
expect(
tester.getSemantics(find.byIcon(Icons.delete)),
matchesSemantics(
label: 'Удалить задачу',
isButton: true,
hasEnabledState: true,
isEnabled: true,
),
);
handle.dispose();
});
Команда командной строки для быстрой проверки на устройстве:
# Включить TalkBack через adb
adb shell settings put secure enabled_accessibility_services com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService
# Проверить дерево семантики в отладчике Flutter
flutter inspect --tree semantics
Размер шрифта и dynamic type
Flutter уважает системный масштаб текста через MediaQuery.textScalerOf(context). По умолчанию Text-виджеты масштабируются автоматически. Если нужно ограничить масштаб (например, в иконках с текстом), используется textScaler: TextScaler.linear(1.0) — но делать это следует осознанно, так как это ухудшает доступность для слабовидящих.
Подводные камни
- Кастомные виджеты, нарисованные через
CustomPainter, невидимы для скринридера без явногоSemantics-обёртки — Engine не может интерпретировать произвольные пиксели. MergeSemanticsне работает поверх виджетов, которые уже имеют действия (onTap и т. п.) — дочерние действия теряются при слиянии, если не указать их явно в родительскомSemantics.- Отключение семантики через
ExcludeSemanticsна контейнере с кнопками внутри лишает пользователей скринридера доступа ко всем дочерним интерактивным элементам, а не только декоративным. - Порядок фокуса TalkBack/VoiceOver следует порядку
SemanticsNodeв дереве, а не визуальному порядку — при наложениях черезStackфокус может переходить в неожиданном порядке. - Использование
Semantics(label: ...)безexcludeSemantics: trueнаImageприводит к дублированию: объявляется и label, и alt-текст из атрибутаsemanticLabelсамогоImage. - Анимированные виджеты с постоянными перестройками (например, shimmer-эффект) непрерывно обновляют SemanticsTree, что перегружает TalkBack и делает его неотзывчивым — нужен
ExcludeSemanticsна время анимации. - На iOS
SemanticsAction.customActionне транслируется напрямую в UIAccessibility custom actions без дополнительного кода на уровне FlutterViewController — стандартные жесты VoiceOver могут не срабатывать. - Минимальный рекомендуемый размер touch-цели — 48×48 dp (Material) и 44×44 pt (iOS HIG). Flutter не применяет это ограничение автоматически; маленькие иконки без явного
SizedBoxилиPaddingнарушают рекомендации WCAG 2.5.5.
Common mistakes
- Сводить «Flutter реализует функции доступности (accessibility)» к синтаксису и не объяснять platform channel.
- Игнорировать жизненный цикл, основной поток или момент освобождения ресурсов в сценарии flutter-24.
- Выбирать API по привычке, не проверяя состояние, ошибки, доступность и платформенные ограничения.
What the interviewer is testing
- Формулирует точную модель для «Flutter реализует функции доступности (accessibility)» и подтверждает ее корректным примером.
- Умеет связать ответ с widget tree, тестированием и отладкой на устройстве.
- Называет ограничения подхода flutter-24, включая производительность, память и сопровождение.