Irbisa · Шпаргалка

Доступность и локализация

Semantics, скринридеры, масштаб текста, flutter_localizations, ARB, intl, плюралы, RTL

Middle-разработчик · 21 вопрос

  1. 01

    Что делает виджет Semantics и чем от него отличаются MergeSemantics и ExcludeSemantics?

    Semantics описывает, что часть интерфейса значит для вспомогательных технологий: подпись (label), роль вроде button или header, значение и доступные действия.

    // Semantics: say what a custom widget meansSemantics(  label: 'Rating',  value: '$stars of 5',  child: ExcludeSemantics(child: StarRow(stars: stars)), // 5 icons -> 1 node);…
  2. 02

    TalkBack озвучивает иконку в app bar просто как «кнопка». Как дать имя элементу управления из одной иконки?

    Дайте ему доступное имя: IconButton(tooltip: 'Search products') передаёт подсказку скринридеру как имя кнопки, а голая Icon принимает semanticLabel.

    // wrong: announced as "button" and nothing elseIconButton(icon: const Icon(Icons.search), onPressed: _openSearch); // right: the tooltip is read as the name; sighted users see it on long-pressIconButton(  icon: const Icon(Icons.search),…
  3. 03

    Как локализовать Flutter-приложение: за что отвечают flutter_localizations, ARB-файлы, gen-l10n и intl?

    Строки пишутся в ARB-файлы, flutter gen-l10n превращает их в типизированный класс AppLocalizations, flutter_localizations даёт переводы собственных текстов Material и Cupertino, а intl форматирует множественное число, даты и числа.

    // l10n.yaml:  arb-dir: lib/l10n//             template-arb-file: app_en.arb// lib/l10n/app_en.arb://   { "cartTitle": "Cart",//     "@cartTitle": { "description": "Title of the cart screen" } }// lib/l10n/app_ru.arb:  { "cartTitle": "Корзина" }…
  4. 04

    Как сделать Flutter-приложение доступным: что вы проверяете на каждом экране?

    Большую часть дают виджеты Material; ваша работа касается кастомных частей: у каждого элемента управления есть имя и роль, зоны нажатия 48 dp, контраст проходит, вёрстка выдерживает текст 200%, а порядок чтения со скринридером имеет смысл.

  5. 05

    Что TalkBack озвучит для этой строки и что изменится, если удалить MergeSemantics?

    bool agreed = false;MergeSemantics(  child: Row(    children: [      Checkbox(        value: agreed,        onChanged: (v) => setState(() => agreed = v ?? false),      ),      const Text('I agree to the terms'),    ],  ),)

    Одну остановку фокуса: подпись, роль и состояние читаются вместе, примерно «I agree to the terms, флажок, не отмечен» (порядок и формулировка зависят от версии TalkBack и языка).

  6. 06

    Карточку товара с названием, ценой и зоной нажатия читают как три отдельные остановки. Как сделать её одной кнопкой?

    Дайте карточке собственный узел семантики с ролью, вручную написанной подписью и действием нажатия, а узлы детей отбросьте: Semantics(container: true, button: true, label: ..., onTap: ..., excludeSemantics: true, child: card).

    // wrong: three stops - "Name", "5 EUR" and an unnamed tap regionStack(children: [  Column(children: [Text(name), Text('$price EUR')]),  Positioned.fill(child: InkWell(onTap: _open)),]);…
  7. 07

    Найдите баг в этом самодельном переключателе: он выглядит правильно и переключается по нажатию, но пользователь TalkBack не понимает, включён ли он.

    Semantics(  button: true,  label: label,  child: GestureDetector(    onTap: () => onChanged(!value),    child: AnimatedContainer(      duration: const Duration(milliseconds: 200),      width: 64,      height: 48,      alignment: value ? Alignment.centerRight : Alignment.centerLeft,      color: value ? Colors.green : Colors.grey,      child: const CircleAvatar(radius: 20, backgroundColor: Colors.white),    ),  ),)

    У узла есть имя и роль button, но нет состояния: «включено» и «выключено» передаются только цветом и положением ползунка, поэтому TalkBack в обоих случаях читает подпись и «кнопка», а после двойного тапа пользователь не слышит, что что-то изменилось.

  8. 08

    Фильтр меняет число результатов, но скринридер молчит. Как объявить изменение, на котором нет фокуса?

    Пометьте меняющийся текст как live region: Semantics(liveRegion: true, child: Text('$n results')).

    // right: status text that is re-read whenever it changesSemantics(  liveRegion: true,  child: Text('$count results'),);…
  9. 09

    Tab прыгает по форме в бессмысленном порядке, хотя выглядит она нормально. Что задаёт этот порядок?

    Его задаёт геометрия, а не порядок, в котором вы написали виджеты: стандартная ReadingOrderTraversalPolicy сортирует фокусируемые виджеты по положению с учётом текущей Directionality, строка за строкой.

    // wrong: two visual columns; traversal zigzags left-right-left-rightRow(children: [  Column(children: [TextField(controller: firstName), TextField(controller: lastName)]),  Column(children: [TextField(controller: street), TextField(controller: city)]),]);…
  10. 10

    Аудит ругает ваш ряд кастомных чипов за размер зоны нажатия и контраст, а соседние кнопки Material пропускает. Что делает Material, чего не делаете вы?

    Material расширяет зону нажатия любого элемента управления до 48x48 dp, каким бы маленьким он ни выглядел, и берёт цвета из ColorScheme, где пары подобраны с учётом контраста.

    // wrong: 24 dp target, padding outside the detector, ~2:1 contrastPadding(  padding: const EdgeInsets.all(12),  child: GestureDetector(    onTap: _remove,    child: const Icon(Icons.close, color: Color(0xFFBDBDBD)),…
  11. 11

    Пользователь включил «Уменьшение движения», а онбординг всё равно делает параллакс и листается сам. Какие флаги есть в MediaQuery и что должно поменяться?

    Flutter выставляет системные переключатели доступности в MediaQuery, но сам учитывает лишь часть (Text делает шрифт жирным, AnimationController идёт на 5% длительности).

  12. 12

    Эта подпись работает на английском. Что она покажет по-русски для 1, 2, 5 и 21 товара и как это исправить?

    String itemsLabel(int n, String lang) {  if (lang == 'ru') {    return '$n ${n == 1 ? 'товар' : 'товаров'}';  }  return '$n ${n == 1 ? 'item' : 'items'}';}

    Она покажет «1 товар», «2 товаров», «5 товаров» и «21 товаров»: два варианта из четырёх неверны, потому что в русском форма выбирается из трёх по последним цифрам, а не по принципу «один или много».

  13. 13

    Немецкий пользователь видит цену как $1,234.50, дату как 12/03/2026, а событие показывается не в тот час. Что не так с кодом форматирования?

    Два упрощения: форматирование без локали и часовой пояс телефона вместо пояса события.

    import 'package:timezone/data/latest.dart' as tzdata;import 'package:timezone/timezone.dart' as tz; // wrong: pattern frozen to one country, no locale, the handset's zoneText('${DateFormat('dd/MM/yyyy HH:mm').format(start.toLocal())} '    '${NumberFormat('\$#,##0.00').format(price)}');…
  14. 14

    Приложение запускается на английском на испанском телефоне, хотя app_es.arb есть. Как Flutter выбирает локаль и как добавить переключатель языка в приложении?

    Flutter сопоставляет список предпочитаемых локалей пользователя с supportedLocales, а при отсутствии совпадения берёт первую поддерживаемую.

    final localeStore = ValueNotifier<Locale?>(null); // null = follow the system Future<void> main() async {  WidgetsFlutterBinding.ensureInitialized();  final prefs = await SharedPreferences.getInstance();  final code = prefs.getString('locale');…
  15. 15

    Найдите баг: после выбора немецкого во встроенном переключателе языка все экраны меняют язык, кроме этого, пока его не открыть заново.

    class _OrdersPageState extends State<OrdersPage> {  late final l10n = AppLocalizations.of(context)!;   @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(title: Text(l10n.ordersTitle)),      body: widget.orders.isEmpty          ? Center(child: Text(l10n.noOrders))          : OrderList(orders: widget.orders),    );  }}

    Поле late final читает AppLocalizations один раз, при первом build, и держит этот английский экземпляр всё время жизни State.

  16. 16

    В арабской версии иконка в строке остаётся слева, а отступ оказывается не с той стороны. Что Flutter отражает сам, а что не отразит никогда?

    Всё, что читает окружающую Directionality, отражается само; всё, что называет физическую сторону, не отражается никогда.

    // wrong: physical sides - identical in Arabic, so wrong in ArabicPadding(  padding: const EdgeInsets.only(left: 16, right: 8),  child: Align(alignment: Alignment.centerLeft, child: Text(name)),);…
  17. 17

    Ваши виджет-тесты проверяют find.text('Save'), а немецкий экран всё равно уехал в релиз без перевода. Как тестировать локализованный UI?

    Запускайте виджет внутри настоящего Localizations с явно заданной локалью и сравнивайте с теми же сгенерированными строками, которые использует UI.

    Widget wrap(Widget child, Locale locale) => MaterialApp(      locale: locale, // pins this test's language      localizationsDelegates: AppLocalizations.localizationsDelegates,      supportedLocales: AppLocalizations.supportedLocales,      home: child,    );…
  18. 18

    Бэкенд возвращает {"code": "INSUFFICIENT_FUNDS"}, а ваш Bloc хранит готовую фразу для показа. Что сломается и где должен происходить перевод?

    Переводите на краю слоя представления: слой данных превращает код из ответа в типизированную ошибку, виджет превращает эту ошибку в текст через AppLocalizations, и ничто между ними не хранит строки для показа.

    sealed class Failure { const Failure(); }class InsufficientFunds extends Failure { const InsufficientFunds(this.shortfall); final num shortfall; }class SessionExpired extends Failure { const SessionExpired(); }class UnknownFailure extends Failure { const UnknownFailure(this.code); final String code; } // Data layer: wire code -> typed failure, no display text…
  19. 19

    PR добавляет тулбар из одних иконок. Что о его доступности может доказать виджет-тест, а для чего всё ещё нужно устройство с TalkBack?

    Виджет-тест доказывает механические правила через meetsGuideline: размер зоны нажатия, контраст текста и подпись у каждого нажимаемого узла.

    testWidgets('editor toolbar meets the accessibility guidelines', (tester) async {  await tester.pumpWidget(const MaterialApp(home: EditorPage()));   await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); // 48 dp  await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); // 44 pt  await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));…
  20. 20

    TalkBack полностью пропускает ваш график выручки. Как дать CustomPainter или кастомному RenderObject настоящую семантику?

    Канвас производит пиксели, а мост доступности видит только узлы семантики, поэтому CustomPaint молчит, пока вы его не опишете: одной итоговой подписью Semantics вокруг него или узлами по областям из CustomPainter.semanticsBuilder.

    class BarsPainter extends CustomPainter {  BarsPainter(this.values, this.labels);  final List<double> values;  final List<String> labels;   @override…
  21. 21

    Вы растите приложение с одного языка до многих. Что нужно положить в CI и чем помогает псевдолокализация?

    Один английский ARB служит источником истины, CI синхронизирует его с платформой перевода, сборка падает на недостающих переводах, а псевдолокализация находит поломки вёрстки раньше любого переводчика.