Semantics описывает, что часть интерфейса значит для вспомогательных технологий: подпись (label), роль вроде button или header, значение и доступные действия. MergeSemantics сворачивает всё поддерево в один узел, а ExcludeSemantics убирает поддерево из дерева.
Дерево семантики: рядом с деревом рендеринга Flutter строит дерево узлов с подписями, флагами и действиями; движок передаёт его TalkBack, VoiceOver или дереву доступности браузера
Большинство виджетов описывают себя сами: Text, кнопки, TextField, Checkbox и Switch уже добавляют узлы; Semantics нужен для кастомных виджетов и недостающих подписей
Строится только по запросу: Flutter пропускает проход семантики, пока её не запросит скринридер, Switch Access или тест, поэтому большинство пользователей за неё не платят
Как посмотреть: showSemanticsDebugger: true в MaterialApp рисует узлы поверх UI, debugDumpSemanticsTree() печатает их в консоль
FlutterMiddle
Доступность и локализация
Semantics, скринридеры, масштаб текста, flutter_localizations, ARB, intl, плюралы, RTL
Закрыто вопросов
0 / 21
- 0Повторить
- 2Часто спрашивают
Вопросы
Дайте ему доступное имя: IconButton(tooltip: 'Search products') передаёт подсказку скринридеру как имя кнопки, а голая Icon принимает semanticLabel. Иконка представляет собой просто глиф шрифта, поэтому без них у узла есть роль и действие, но нечего прочитать вслух.
Самодельные нажимаемые элементы: GestureDetector добавляет действие нажатия, но не роль, поэтому оберните его в Semantics(button: true, label: ...) или возьмите IconButton: он даёт роль, подсказку и зону нажатия 48 dp
Называйте действие, а не картинку: «Ещё», а не «три точки», и без слова «кнопка»: роль скринридер объявит сам
Изображения: semanticLabel у картинки, которая несёт смысл, excludeFromSemantics: true у декоративной
Прячьте декор: ExcludeSemantics не даёт бессмысленной иконке стать ещё одной остановкой при свайпе
Строки пишутся в ARB-файлы, flutter gen-l10n превращает их в типизированный класс AppLocalizations, flutter_localizations даёт переводы собственных текстов Material и Cupertino, а intl форматирует множественное число, даты и числа.
Настройка: flutter_localizations (из SDK) и intl в pubspec.yaml, generate: true в секции flutter: и файл l10n.yaml, где указаны папка с ARB и файл-шаблон
ARB: JSON, по ключу на сообщение; шаблон (app_en.arb) содержит ещё и метаданные @key: описание для переводчика и типизированные плейсхолдеры
Сгенерированный код: по геттеру или методу на ключ, так что опечатка становится ошибкой компиляции; код пишется в ваше дерево исходников (например, lib/l10n), потому что синтетический импорт package:flutter_gen убрали
Подключение: MaterialApp(localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales)
Классический null: AppLocalizations.of(context) ищет унаследованный виджет, поэтому возвращает null в том же build, который создаёт MaterialApp; читайте его в потомке
Большую часть дают виджеты Material; ваша работа касается кастомных частей: у каждого элемента управления есть имя и роль, зоны нажатия 48 dp, контраст проходит, вёрстка выдерживает текст 200%, а порядок чтения со скринридером имеет смысл.
Имена и роли: tooltip у кнопок-иконок, semanticLabel у содержательных картинок, Semantics(button: true, ...) у кастомных нажимаемых элементов, header: true у заголовков разделов
Группировка: один узел на карточку или строку списка (MergeSemantics или свой Semantics), ExcludeSemantics для декора
Зоны нажатия и контраст: 48x48 dp (44 pt на iOS); 4.5:1 для основного текста, 3:1 для крупного текста и иконок
Масштаб текста: никаких фиксированных высот вокруг текста, никаких maxLines: 1 у важных подписей, никакого ограничения TextScaler на всё приложение
Изменения без фокуса: live region или SnackBar для результатов, которые появляются сами; учитывайте MediaQuery.disableAnimationsOf
Проверяйте дважды: meetsGuideline в виджет-тестах для механических правил, затем проход с TalkBack и VoiceOver, потому что только человек слышит, осмыслены ли порядок и формулировки
Одну остановку фокуса: подпись, роль и состояние читаются вместе, примерно «I agree to the terms, флажок, не отмечен» (порядок и формулировка зависят от версии TalkBack и языка). Без MergeSemantics остановок две (чекбокс без имени и текст без действия).
Чем плох раздельный вариант: пользователь слышит «флажок, не отмечен», не понимая, с чем соглашается, а потом фразу, которая ничего не делает по двойному тапу
Слияние переносит и действие: двойной тап в любом месте объединённого узла переключает чекбокс, как это делает label в вебе
Сливается только семантика: зрячему пользователю всё равно нужно попасть в сам чекбокс; CheckboxListTile и объединяет семантику, и делает нажимаемой всю строку
Не сливайте два действия: в объединённой строке с кнопками «Принять» и «Отклонить» достижимой останется только одна
Дайте карточке собственный узел семантики с ролью, вручную написанной подписью и действием нажатия, а узлы детей отбросьте: Semantics(container: true, button: true, label: ..., onTap: ..., excludeSemantics: true, child: card).
**Зачем onTap на узле**: исключение детей отбрасывает и действие нажатия InkWell, поэтому его должен нести созданный вами узел, иначе двойной тап ничего не сделает
**Если InkWell оборачивает содержимое**: Flutter уже сливает его в один узел «Name 5 EUR» с действием нажатия, но без роли button; тогда вы лишь добавляете роль и свою подпись
**MergeSemantics вместо этого: он склеивает подписи детей в порядке дерева; подходит для «Battery» + «87%», но неуклюж для карточки, где фразу хочется написать самому
Три слота**: label задаёт имя, value описывает меняющуюся часть, hint говорит, что произойдёт при действии («Открывает карточку товара»; «дважды коснитесь» скринридер добавит сам)
Роль задают флаги: button, link, header, selected, checked; именно роль заставляет скринридер сказать, как элемент активировать
Ловушка: если слить карточку с двумя кнопками («В корзину», «В избранное»), они схлопнутся в одно действие; такие кнопки оставляйте отдельными узлами
У узла есть имя и роль button, но нет состояния: «включено» и «выключено» передаются только цветом и положением ползунка, поэтому TalkBack в обоих случаях читает подпись и «кнопка», а после двойного тапа пользователь не слышит, что что-то изменилось.
Почему так: Semantics сообщает только то, что вы задали; button: true даёт роль, GestureDetector добавляет действие нажатия, но ничто не говорит системе, что это переключатель и какое у него значение
Исправление: toggled: value вместо button: true: узел получает флаг состояния переключения, скринридер объявляет его как переключатель со словом «вкл.» или «выкл.», а каждый ребилд с новым value обновляет узел
Выбирайте нужный флаг: toggled для переключателей, checked для чекбоксов и радиокнопок, selected для вкладок и чипов; button остаётся для обычных действий
Самое простое исправление: взять Switch или SwitchListTile и перестилизовать их: Material-контролы ставят эти флаги сами
Как поймать в следующий раз: гайдлайны этот виджет пропускают (он 48 dp и подписан), поэтому проверяйте состояние: сначала final handle = tester.ensureSemantics(); (без него getSemantics бросает исключение), затем expect(tester.getSemantics(find.byType(SettingToggle)), containsSemantics(hasToggledState: true, isToggled: true)), в конце handle.dispose();
Пометьте меняющийся текст как live region: Semantics(liveRegion: true, child: Text('$n results')). Когда подпись этого узла меняется, скринридер произносит её, не перемещая фокус.
Узел должен остаться в дереве: если заменить его виджетом с другим ключом, появится новый узел вместо изменённого, и объявления может не быть
**SnackBar это уже делает**: его содержимое и есть live region; самодельный тост в Overlay этого не делает
На экране нечего пометить: SemanticsService.sendAnnouncement(View.of(context), message, textDirection); старый announce устарел с 3.35, потому что не умеет адресовать окно
Android: проверяйте MediaQuery.supportsAnnounceOf(context): там он false, потому что Android объявил такие анонсы устаревшими (TalkBack ради них очищает очередь речи), так что используйте live region
Ловушки: live region на тикающем таймере не замолкает никогда, а текст, на котором стоит фокус, прозвучит дважды
Его задаёт геометрия, а не порядок, в котором вы написали виджеты: стандартная ReadingOrderTraversalPolicy сортирует фокусируемые виджеты по положению с учётом текущей Directionality, строка за строкой.
Два порядка, два механизма: Tab и стрелки идут по дереву фокуса; TalkBack, VoiceOver и Switch Access идут по дереву семантики, и оно переупорядочивается через Semantics(sortKey: OrdinalSortKey(1)). Исправив один, вы не исправите другой
Сначала группируйте: FocusTraversalGroup вокруг поддерева обходит всё внутри, прежде чем двигаться дальше: по одной на колонку, тулбар или диалог
Явный порядок задавайте, только когда группировка не помогает: OrderedTraversalPolicy плюс FocusTraversalOrder(order: NumericFocusOrder(2), ...)
Где ломается: у Stack или Wrap нет очевидного порядка чтения, а кастомный диалог должен сам переносить фокус в себя при открытии и возвращать его при закрытии
Material расширяет зону нажатия любого элемента управления до 48x48 dp, каким бы маленьким он ни выглядел, и берёт цвета из ColorScheme, где пары подобраны с учётом контраста. GestureDetector вокруг иконки 24 dp получает 24 dp и тот цвет, который вы вписали.
Цифры: 48x48 dp на Android, 44x44 pt на iOS, около 8 dp между соседями; kMinInteractiveDimension равен 48
Задавайте размер зоны нажатия, а не глифа: SizedBox вокруг InkWell, а отступы внутри нажимаемого виджета; отступ снаружи не участвует в hit test
Проверьте тему: materialTapTargetSize: MaterialTapTargetSize.shrinkWrap или VisualDensity.compact на всё приложение (частая находка аудита)
Контраст считается для пары цветов: 4.5:1 для основного текста, 3:1 для крупного текста, иконок и границ элементов управления; Colors.grey.shade400 на белом даёт около 2:1
Типичные нарушители: состояния disabled, подсказки и плейсхолдеры, текст поверх фото без затемняющей подложки
Flutter выставляет системные переключатели доступности в MediaQuery, но сам учитывает лишь часть (Text делает шрифт жирным, AnimationController идёт на 5% длительности). Остальное (параллакс, Lottie, видео, автокарусели) проверяйте через MediaQuery.disableAnimationsOf(context); берите fade.
**disableAnimationsOf: «Уменьшение движения» на iOS и «Отключить анимацию» на Android; флаг движения, который работает на обеих платформах
Только iOS**: highContrastOf, invertColorsOf и onOffSwitchLabelsOf на Android всегда false, поэтому не завязывайте функцию только на них
Ещё стоит читать: accessibleNavigationOf (включён скринридер, на iOS ещё и Switch Control), boldTextOf и textScalerOf для размера шрифта
Что менять: никаких сдвигов, параллакса и зумов, никаких каруселей, которые листаются сами; укороченная длительность их не заменяет. Уменьшение движения означает отсутствие крупных перемещений, а не отсутствие обратной связи: cross-fade допустим
Высокому контрасту ветка не нужна: передайте highContrastTheme и highContrastDarkTheme в MaterialApp, и на iOS тема переключится сама
Она покажет «1 товар», «2 товаров», «5 товаров» и «21 товаров»: два варианта из четырёх неверны, потому что в русском форма выбирается из трёх по последним цифрам, а не по принципу «один или много». Перенесите всю фразу в ARB-сообщение с ICU plural.
Русские правила (CLDR): one для 1, 21, 101, но не 11 («товар»), few для 2–4, 22–24 («товара»), many для 0, 5–20, 25 («товаров»), other для дробных
Исправление: в app_ru.arb: "cartItems": "{count, plural, one{{count} товар} few{{count} товара} many{{count} товаров} other{{count} товара}}", а в плейсхолдерах @cartItems шаблона нужен "count": {"type": "int"}; UI вызывает l10n.cartItems(n)
Категории принадлежат языку: английскому шаблону нужны только one и other; каждый перевод добавляет то, что нужно его языку, а other обязателен всегда. =0{Корзина пуста} обрабатывает точное число
**select**: тот же синтаксис для пола или любого enum: {gender, select, female{...} male{...} other{...}}
Корень проблемы: грамматику, написанную в Dart, переводчик не видит; у фразы, склеенной из двух ключей, та же беда
Два упрощения: форматирование без локали и часовой пояс телефона вместо пояса события. Передавайте локаль приложения в каждый DateFormat и NumberFormat, используйте скелеты вместо жёстких шаблонов и переводите время события в его собственный пояс.
Скелет, а не шаблон: DateFormat.yMMMd(locale) просит «год, короткий месяц, день» и даёт локали расставить их; DateFormat('dd/MM/yyyy') замораживает обычай одной страны
У чисел тоже есть локаль: NumberFormat.currency(locale: 'de_DE', symbol: '€') даёт 1.234,50 €; без локали intl берёт Intl.defaultLocale, а это en_US, если вы его не задали
Берите локаль приложения: Localizations.localeOf(context), а не устройства: они разойдутся в тот день, когда появится переключатель языка в приложении
**Вне MaterialApp**: сначала вызовите initializeDateFormatting(locale), иначе DateFormat бросит LocaleDataException
Часовые пояса: toLocal() берёт пояс телефона: это верно для «когда это случилось», но неверно для рейса или часов работы. Храните UTC, передавайте IANA-пояс события отдельным полем и конвертируйте через package:timezone
Flutter сопоставляет список предпочитаемых локалей пользователя с supportedLocales, а при отсутствии совпадения берёт первую поддерживаемую. Приложение, застрявшее на английском, обычно не передало supportedLocales (по умолчанию там только en_US) или на iOS не объявило языки в Info.plist.
Порядок выбора: ненулевой MaterialApp.locale заменяет список устройства одной этой локалью, но проходит те же шаги: localeListResolutionCallback или localeResolutionCallback, если вернул не null, затем стандартный алгоритм (точное совпадение, совпадения по языку/письменности/стране, потом supportedLocales.first). locale, которой нет в supportedLocales, молча заменяется
Первая поддерживаемая локаль: AppLocalizations.supportedLocales генерируется по алфавиту (de, en, ru), поэтому фолбэком здесь оказывается немецкий; чтобы выбрать язык шаблона, верните его из localeResolutionCallback
iOS: перечислите все языки в CFBundleLocalizations в Info.plist; iOS передаёт приложению только те предпочитаемые языки, которые объявлены в бандле
Переключатель в приложении: храните Locale? в глобальном состоянии, где null означает «как в системе», и передавайте его в MaterialApp.locale; загрузите сохранённый выбор до runApp, иначе первый кадр мелькнёт на системном языке
Android 13+: объявите android:localeConfig, чтобы приложение появилось и в системных настройках языка для отдельных приложений
Поле late final читает AppLocalizations один раз, при первом build, и держит этот английский экземпляр всё время жизни State. Смена локали страницу перестраивает, но ребилд берёт закэшированный объект, поэтому строки остаются английскими.
Почему так: AppLocalizations.of(context) регистрирует зависимость, поэтому страница узнаёт о новой локали; но l10n больше не вычисляется, а смена локали сохраняет State, а не создаёт новый
Почему код выглядел нормально: late откладывает поиск до первого build, поэтому строка не падает, как упала бы в initState, а с одним языком ничего не ломается
Исправление: получайте его в build: вызов сводится к дешёвому поиску унаследованного виджета, и каждый ребилд получает строки текущей локали
Та же ловушка: late final theme = Theme.of(context) пропускает переключение на тёмную тему, а NumberFormat или DateFormat, созданный один раз в поле, остаётся со старой локалью
Как поймать в следующий раз: виджет-тест, который отрисовывает экран с Locale('en'), затем с Locale('de') и ожидает немецкий заголовок; на ревью считайте багом любой X.of(context), сохранённый в поле State
Всё, что читает окружающую Directionality, отражается само; всё, что называет физическую сторону, не отражается никогда. Row, EdgeInsetsDirectional и TextAlign.start переворачиваются, а EdgeInsets.only(left:), Alignment.centerLeft и Positioned(left:) остаются там, где вы их поставили.
Меняйте один к одному: EdgeInsetsDirectional.only(start:, end:), AlignmentDirectional.centerStart, PositionedDirectional(start:), BorderRadiusDirectional, TextAlign.start
Уже учитывают направление: Row, MainAxisAlignment.start, горизонтальный ListView: они означают «в начале», а не «слева»
Иконки: направленные иконки Material вроде Icons.arrow_back и Icons.chevron_left имеют matchTextDirection: true и отражаются сами; кастомный SVG сам не отражается, отразите его через Transform.flip. Никогда не отражайте часы, кнопки медиаплеера, логотип и цифры
Ловушка: забытый Offset(8, 0) или Transform.translate: вёрстка отражается, а бейдж нет. Проверяйте каждый экран с принудительным TextDirection.rtl заранее, а не после арабского релиза
Запускайте виджет внутри настоящего Localizations с явно заданной локалью и сравнивайте с теми же сгенерированными строками, которые использует UI. Тогда тест доказывает, что экран подключён к переводам, а не закрепляет английские слова.
Настройка тестового приложения: сгенерированные localizationsDelegates и supportedLocales плюс locale:; без делегатов AppLocalizations.of(context)! бросает исключение, и падение выглядит как баг виджета
Проверяйте ключ, а не фразу: lookupAppLocalizations(const Locale('de')) даёт строки, которые покажет приложение; find.text(l10n.saveButton) переживает правки текста и падает, когда виджет захардкодил английский
Покройте каждое поведение, зависящее от локали, по разу: переход множественного числа от одного к пяти, скелет даты, одну RTL-вёрстку
Системная локаль: tester.platformDispatcher.localesTestValue подменяет языки устройства, чтобы проверить фолбэк; фолбэком служит supportedLocales.first, а сгенерированный список отсортирован по алфавиту, так что это не язык шаблона
Отсутствующие переводы проверяйте на сборке: untranslated-messages-file в l10n.yaml и падение CI, если файл содержит что-либо, кроме {}; для длинных немецких строк нужен golden-тест на самых плотных экранах
Переводите на краю слоя представления: слой данных превращает код из ответа в типизированную ошибку, виджет превращает эту ошибку в текст через AppLocalizations, и ничто между ними не хранит строки для показа.
Что ломается
Смена языка: в состоянии остаётся старая фраза, потому что её никто не пересчитывает: экран сменил язык, а ошибка нет
Слои: Bloc или репозиторию, который переводит, нужен BuildContext или переданная локаль, а его тесты проверяют английскую прозу
Рабочая схема
Протокол: сервер присылает стабильный код и структурированные параметры: {"code": "INSUFFICIENT_FUNDS", "params": {"shortfall": 12.5}}
Слой данных: сопоставляет коды с sealed-иерархией Failure; неизвестный код становится UnknownFailure(code)
Виджет: делает switch по ошибке и вызывает соответствующее ARB-сообщение, форматируя числа текущей локалью; sealed-тип превращает новую ошибку без сообщения в ошибку компиляции
Неизвестные коды: общее сообщение, которое всё же показывает сырой код, чтобы поддержка могла работать по скриншоту
Контент, которым владеет сервер
Названия в каталоге, тексты из CMS: отправляйте Accept-Language по локали приложения, а не устройства, и включайте локаль в ключ любого дискового кэша, иначе после смены языка покажется вчерашний язык
Виджет-тест доказывает механические правила через meetsGuideline: размер зоны нажатия, контраст текста и подпись у каждого нажимаемого узла. Никакая автоматика не скажет, осмысленно ли то, что произносит скринридер, и в каком порядке.
Что может доказать тест
Четыре встроенных правила: androidTapTargetGuideline (48 dp), iOSTapTargetGuideline (44 pt), labeledTapTargetGuideline и textContrastGuideline (4.5:1), каждое через await expectLater(tester, meetsGuideline(...))
Семантика включена по умолчанию: у testWidgets стоит semanticsEnabled: true, поэтому вызов tester.ensureSemantics() из старых примеров больше не нужен; если её выключить, эти проверки теряют смысл
Один узел за раз: containsSemantics(tooltip: 'Archive', isButton: true, hasTapAction: true) против tester.getSemantics(finder); подсказка IconButton приходит как tooltip, а не label. matchesSemantics даёт строгую версию, которая падает на любом неуказанном флаге
Что правила пропускают
Контраст поверх картинок: textContrastGuideline анализирует отрисованные пиксели вокруг каждого текста, поэтому поверх фото или градиента его вердикт ненадёжен
Смысл и порядок: формально допустимый, но абсурдный порядок чтения, «archive button button», диалог, который не переносит фокус в себя, слишком болтливый live region
На устройстве
TalkBack и VoiceOver: один раз пройдите свайпами новый экран с закрытыми глазами; Android Accessibility Scanner и Accessibility Inspector в Xcode читают настоящую семантику Flutter-приложения
Во время разработки: showSemanticsDebugger: true или debugDumpSemanticsTree()
Канвас производит пиксели, а мост доступности видит только узлы семантики, поэтому CustomPaint молчит, пока вы его не опишете: одной итоговой подписью Semantics вокруг него или узлами по областям из CustomPainter.semanticsBuilder.
Один итоговый узел
Обычно лучший ответ: Semantics(label: 'Revenue, 12 months, from 3.2 thousand in January to 9.8 thousand in December', child: ExcludeSemantics(child: chart)); одна фраза лучше двенадцати остановок при свайпе
semanticsBuilder
Узлы по областям: верните по одному CustomPainterSemantics на столбец, у каждого непустой rect в координатах painter'а; для label нужен ещё и textDirection
**shouldRebuildSemantics**: по умолчанию он возвращает shouldRepaint; переопределите его, когда подписи зависят от данных, которые проверка перерисовки не учитывает, иначе они застынут, пока пиксели обновляются
**Текст, нарисованный через TextPainter**: невидим по той же причине, так что подпись оси, нарисованная на канвасе, подписью не считается
Кастомный RenderObject
**describeSemanticsConfiguration**: задайте isSemanticBoundary, label, value, textDirection и действия вроде onTap или onIncrease
Обновляйте её: вызывайте markNeedsSemanticsUpdate() при каждом изменении этих данных; markNeedsPaint() семантику не пересобирает
Один английский ARB служит источником истины, CI синхронизирует его с платформой перевода, сборка падает на недостающих переводах, а псевдолокализация находит поломки вёрстки раньше любого переводчика.
Пайплайн
Исходный файл: app_en.arb с описанием и метаданными плейсхолдеров у каждого сообщения; ключ, использованный и для существительного, и для глагола, вернётся неправильным в половине языков
Переведённые ARB считаются сгенерированными файлами: CI выгружает исходник при мерже и открывает PR с тем, что вернулось; руками их никто не правит
Барьер на сборке: untranslated-messages-file в l10n.yaml, и релизная джоба падает, если в нём есть сообщение для поставляемой локали; сломанный синтаксис ICU и так роняет flutter gen-l10n
Псевдолокализация
Фейковая локаль только для debug: буквы с диакритикой, скобки вокруг каждого сообщения и около 40% добивки, генерируется из app_en.arb небольшим скриптом
Что находит один проход: захардкоженные строки (без диакритики), обрезанный текст (нет закрывающей скобки) и фразы, склеенные из кусков (несколько пар скобок)
Что ломается первым
Немецкий и русский: строки длиннее на 30–40%, поэтому кнопки фиксированной ширины и подписи с maxLines: 1 переполняются
Японский и китайский: перенос строки возможен почти между любыми символами, а шрифтам нужны фолбэки, которые стандартный шрифт может не покрыть
Арабский и иврит: RTL-отражение, плюс более высокие глифы, которые обрезает захардкоженный height: в TextStyle