FlutterMiddleSystem design

Как 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, включая производительность, память и сопровождение.

Sources

Related topics

Как Flutter реализует функции доступности (accessibility)? | Talanto