Доступность и мелочи, из-за которых приложение кажется чужим

проект≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

«Подход» умеет всё, ради чего затевался: пишется в зале без сети, синхронизируется, отдаёт дневник владельцу файлом. Осталось то, чего не видно ни в одном прогоне, — и в зале оно решает не меньше, чем протокол синхронизации. Телефон там лежит на скамье, руки заняты и в перчатках, между подходами на экран жмут почти вслепую, а кто-то не смотрит на него вовсе. Эта статья про три такие вещи: строка «жим 82,5 5» звучит совсем не так, как выглядит; крестик в 24 точки промахивается чаще, чем кажется; системный шрифт растёт, а высота строки — нет. И сразу честно про среду: настоящего голоса в предпросмотре нет, поэтому обход по узлам доступности мы соберём миниатюрой — зато разметка, которую читает скринридер, здесь настоящая, и она же едет во второе устройство «Подхода», веб-версию.

Разделы темы:·

Строка ленты собрана как в любом приложении: View, внутри три Text — упражнение, вес, повторы, — а справа крестик «удалить подход». Глазами это одна строка. Голосом — не одна: скринридер ходит не по тому, что нарисовано, а по узлам доступности, и каждый такой узел — отдельная остановка, на которой голос называет подпись, роль и состояние.

Правил, из которых складывается обход, три, и все они записаны в документации React Native и в типах. Первое: Text и всё нажимаемое — узлы доступности сами по себе, обычный View — нет. Второе: пропс accessible делает узлом сам View, и тогда обход внутрь не идёт вовсе — поддерево склеивается в одну остановку. Третье: если подписи нет, она собирается сама — обходом всех потомков со склейкой текста через пробел, как сказано про accessibilityLabel в ViewAccessibility.d.ts.

Соберём эти три правила в два десятка строк и прогоним через них три варианта одной и той же строки ленты: как она написана сейчас; вся строка одним доступным узлом с подписью; группа с текстом плюс кнопка снаружи. Прогноз: сколько остановок выйдет в каждом случае и что голос скажет на каждой?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первый вариант — четыре остановки, и ни одна не годится. «82,5» — чего? «5» — чего? Голос читает ячейки таблицы, из которой убрали шапку. А крестик он называет «×», потому что подписи нет, и собрать её не из чего: внутри тот же символ.

Второй вариант выглядит решением и им не является. Остановка одна, текст правильный — а кнопки «Удалить подход» в озвучке нет вовсе. Доступный узел заканчивает обход: всё, что внутри, для голоса перестаёт существовать, включая нажимаемое. На телефоне это значит ровно то, как звучит: удалить подход, не глядя на экран, станет нельзя.

Третий вариант — то, что нужно: две остановки, «жим, 82,5 килограмма, 5 повторов» и «Удалить подход, кнопка». Отсюда правило, к которому сводится почти вся работа над озвучкой списка: группируют текст, нажимаемое оставляют снаружи и подписывают отдельно. Подпись при этом пишется словами, которые человек говорит вслух: «82,5 килограмма», а не «82.5 kg» и не «вес двоеточие».

Это модель, а не голос. VoiceOver в песочницу не поставить, обход здесь наш собственный и по трём правилам из документации. Он годится, чтобы увидеть, где остановок слишком много, а где они пропали. Он не скажет, каково это — слушать ваш экран полчаса подряд; для такого есть телефон, и им статья заканчивается.

Миниатюра показала, что должно произойти. Теперь — что происходит на самом деле, в той среде, где выполняются примеры. Это не только про предпросмотр: react-native-web и есть веб-версия «Подхода», то самое второе устройство из главы 7, и разметка ниже — ровно та, которую на компьютере получит настоящий скринридер.

Пять узлов, и все написаны так, как советуют их писать: заголовок экрана с ролью header; крестик с подписью, подсказкой и запасом hitSlop; переключатель единиц с ролью switch и состоянием через accessibilityState; он же, но с aria-checked; и декоративная иконка, спрятанная от голоса через importantForAccessibility. Прогноз: что из этого доедет до разметки, а что исчезнет по дороге?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Роль доехала, и даже щедрее, чем просили: Text с ролью header стал настоящим <h1>, а Pressable с ролью button — <button>, который сам попадает в обход с клавиатуры. Подпись доехала тоже. А подсказка, состояние, запас нажатия и «спрятать от голоса» не доехали никак: в разметке их нет ни в каком виде. Диагностик при этом ноль — все пять узлов написаны по настоящим типам react-native 0.86.

Причина не в ошибке, а в списке. У react-native-web есть перечень пропсов, которые он пропускает в разметку (modules/forwardedProps): там все aria-* и role, там старые accessibilityLabel и accessibilityLiveRegion — и нет ни accessibilityState, ни accessibilityHint, ни accessibilityValue. А hitSlop и importantForAccessibility веб-сборка не разбирает вовсе: первый — потому что зону касания в вебе никто не считает («Нажатие — это автомат, а не onClick»), второй — потому что это пропс Android.

Что написалиПредпросмотр и веб-версияТелефон
accessibilityLabelaria-labelподпись, которую называет голос
accessibilityRolerole, а иногда и сам тег: h1, buttonроль: «кнопка», «заголовок», «переключатель»
accessibilityStateне доезжаетсостояние: «включено», «выбрано», «недоступно»
accessibilityHintне доезжаетподсказка после паузы, iOS и Android
accessibilityValueне доезжаетзначение: «45 из 90»
aria-checked и прочие aria-*доезжают как естьпереводятся в accessibilityState
hitSlop, importantForAccessibilityне разбираютсязона касания и «спрятать от голоса»

Отсюда правило для проекта, который живёт на трёх платформах: подпись и роль пишутся одним пропсом, состояние — двумя. Подпись с ролью работают везде; состояние на вебе понимают только aria-*, а на телефоне aria-checked — псевдоним accessibilityState, и react-native сворачивает его в состояние сам. Написать оба всё равно дешевле, чем однажды выпустить переключатель, который молчит о себе. Строка ленты после этого выглядит так:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В листинге у группы стоит role="group", и это не украшение. Подпись доезжает в разметку и без роли — но ARIA запрещает имя у узла без роли (обычный <div> для неё «generic»), поэтому браузер такое имя просто не отдаст. На телефоне тот же accessible склеит узел и без всяких ролей. Прогноз: что окажется в разметке у двух одинаковых групп, одна из которых с ролью?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Подпись стоит у обоих, роль — у второго, и разница в том, кто эту подпись прочтёт. Это и есть цена одинакового кода на трёх платформах: accessible — слово из мира телефона, роль — слово из мира браузера, и «Подход» пишет оба.

Состояние «недоступно» — единственное, о котором не надо рассказывать дважды: disabled у Pressable сам доезжает и до разметки (aria-disabled, tabindex="-1"), и до телефона. Это та же кнопка из «Первого экрана», у которой выключенность и нажатие живут в одном пропсе.

В приложении на запросах экран меняется после нажатия: нажали — подождали — увидели. В local-first половина изменений приходит сама: sync() запускается при возврате из фона, чужая правка приезжает с курсора, индикатор из «Цикла синхронизации» переходит из «ждут отправки: 2» в «сохранено». Глазами это мигнувшая строка. Голосом — ничего: скринридер читает то, на чём стоит фокус, а фокус никуда не двигался.

Способов рассказать два, и они не взаимозаменяемы. Живая область — accessibilityLiveRegion (пропс Android, в вебе это aria-live): система сама зачитывает изменившийся текст. Прямое объявление — AccessibilityInfo.announceForAccessibility(строка): приложение произносит фразу, никак не связанную с фокусом. Прогноз: что покажет разметка индикатора до и после нажатия на «Синхронизировать» и что вернёт объявление?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Текст индикатора сменился, aria-live стоит на месте: в вебе живая область работает по-настоящему, и это тот редкий случай, когда предпросмотр делает ровно то же, что будет у веб-версии. А вот объявление здесь не сделало ничего: announceForAccessibility в react-native-web — пустой метод, как Alert.alert из главы 4. На телефоне всё наоборот: объявление работает на обеих платформах, а живая область — только на Android.

Значит, в «Подходе» объявляются две вещи, обе — итоги, а не шаги: «дневник отправлен» после успешной синхронизации и «нет сети, подход сохранён на телефоне» после неудачной. Прогресс переноса на новое устройство, проценты и каждую применённую операцию озвучивать нельзя: голос встанет в очередь и человек перестанет слышать своё приложение. Правило простое — объявляй то, что человек стал бы ждать, и ровно один раз.

Индикатор показывает состояние дневника, а не состояние запроса, — это решено ещё в «Цикле синхронизации». Озвучивать его надо теми же словами: не «загрузка завершена», а «дневник отправлен»; не «ошибка 503», а «нет сети, подход сохранён на телефоне». Человеку важно, где лежит его запись, а не что ответил сервер.

Вторая вещь, которой не видно в предпросмотре, — размер мишени. Мышь попадает в пиксель, палец накрывает пятно около сантиметра, и целится он хуже, чем кажется его хозяину. Отсюда требования платформ: Apple просит мишень не меньше 44 точек, Google — 48 dp, а типы react-native в описании hitSlop называют 30–40. Крестик «удалить подход» в строке ленты — 24 на 24.

Проверить это нажатием здесь нечем: fireEvent.press — это click по узлу, он попадает всегда, а hitSlop веб-сборка Pressable не разбирает вовсе — это выяснено в «Нажатие — это автомат, а не onClick». Остаётся то, что от среды не зависит: геометрия. Зона касания — прямоугольник кнопки, расширенный запасом, но не выходящий за границы родителя: так hitSlop описан в типах.

Прогноз: крестику 24 на 24 в строке высотой 32 дали hitSlop 10. Какая мишень получится, куда попадут пять нажатий и что изменится, если строку поднять до 44?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Просили 44 на 44, получили 44 на 32: по горизонтали запас лёг целиком, а по вертикали его съела строка — зона не может быть выше родителя. Из пяти нажатий одно попало бы и без запаса, ещё два спас hitSlop, а последние два — промах. И промах снизу (332,36) — тот самый случай, ради которого запас и ставили: он не сработал.

Отсюда вывод, который экономит часы: hitSlop не заменяет размер строки. Пока у строки height: 32, любой запас упирается в её край. Строка с minHeight: 44 — и та же кнопка получает полную мишень (последняя строка вывода). Заодно решается и вторая беда, о которой следующий раздел: minHeight вместо height переживает крупный системный шрифт.

Запас есть и у второго края нажатия: pressRetentionOffset — насколько далеко палец может уехать, не погасив кнопку («Нажатие — это автомат»), и hitSlop прибавляется к нему тоже. А если по строке ещё и свайп («Жест как разговор»), зоны кнопки и жеста стоит разводить руками: спор за касание выигрывает не тот, кто ближе, а тот, кто первым стал ответчиком.

Третья вещь приходит из системных настроек: человек ставит крупный шрифт — и весь текст в приложении растёт вместе с ним, потому что React Native масштабирует его сам, пока у Text не выключен allowFontScaling. Насколько именно — сообщает PixelRatio.getFontScale(). Прогноз: что он ответит в предпросмотре и что станет со строкой ленты, у которой в стилях height: 60?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Единица — и так будет всегда: в Dimensions react-native-web поле fontScale записано единицей, а getFontScale() читает именно его. Значит, крупный шрифт в предпросмотре не проверяется в принципе, и это ровно та подмена, которую «Размеры без пикселей» называли поимённо: здесь fontScale всегда 1.

Зато цену видно по модели. При обычном размере строке нужно 55 точек — в height: 60 она помещается с запасом, и в этот момент код выглядит правильным. При множителе 1,3 нужно уже 67, при двух — 94, а на самых крупных системных размерах iOS — 137. Всё, что не влезло, будет обрезано: не «поедет вниз», а именно обрезано, потому что высота задана числом.

Правил отсюда три, и все дешёвые. Первое: minHeight вместо height везде, где внутри текст, — тогда строка растёт, а мишень из прошлого раздела только выигрывает. Второе: allowFontScaling не выключать; если раскладка ломается на огромных размерах, ограничивают maxFontSizeMultiplier — в прогоне 1,5 удержал строку на 74 вместо 137. Третье: там, где строка обязана остаться одной, ставится numberOfLines с многоточием («Каскада нет, наследования нет»), а не надежда, что текст поместится.

Крупный шрифт ставят не только слабовидящие. Его ставят все, кто читает телефон на вытянутой руке, в движении и в зале, где очки лежат в сумке. Это самая частая настройка доступности вообще — и самая дешёвая в поддержке, если height в стилях с самого начала не было.

Остался вопрос из названия главы: как убедиться, что экран читается голосом. У React Native для этого есть AccessibilityInfo — вопросы к системе и команда «сказать вслух». Прогноз: что он ответит здесь, в предпросмотре?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
«Скринридер включён: true» — не про ваш компьютер: в react-native-web этот метод буквально возвращает готовое true всем и всегда. Поэтому ветка «если включён скринридер, покажем иначе» в веб-версии включится у каждого — и это лучший довод не писать таких веток вовсе, а делать экран пригодным сразу. announceForAccessibility — пустой метод. Честный ответ здесь один, последний: «меньше движения» читает prefers-reduced-motion браузера через matchMedia — так же, как в части II выбирали плавную или мгновенную прокрутку. Поэтому пример и печатает тип, а не значение: у вас оно своё.

Проверка переезжает на устройство, и это опыты с выключателями, а не «посмотреть глазами» — как авиарежим и убийство процесса из «Что в подложке было ненастоящим». Первый опыт задаётся одной командой на каждую платформу:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второй опыт — голос. TalkBack на Android включается быстрым нажатием обеих кнопок громкости, VoiceOver на iPhone — тройным нажатием боковой кнопки, если это назначено в «Быстрой команде». Дальше приложение проходится свайпами, а не пальцем по кнопкам: свайп вправо — следующая остановка. Пройдите ленту, удалите подход, откройте настройки, переключите фунты — и слушайте не интонацию, а содержание: сколько остановок на строку, названа ли роль, говорит ли переключатель о своём состоянии, узнали ли вы о том, что дневник отправился.

Третий опыт — мишени: нажимать не подушечкой указательного пальца сидя за столом, а большим пальцем одной руки, стоя, как в зале. Промахи найдутся в первую минуту, и обычно в тех же местах, где их нашла геометрия выше.

И напоследок — те мелочи, из-за которых приложение кажется чужим, даже когда всё работает. Каждая разобрана в своей статье, здесь они собраны списком, чтобы перед выпуском пройти по нему за один вечер.

МелочьЧем выдаёт себяГде разобрана
Кнопка без подписиголос читает «×» или просто «кнопка»раздел «Что слышит человек…» выше
Мишень меньше 44 точекпалец мажет мимо крестикараздел «Мишень под палец» выше
height вместо minHeightпри крупном шрифте текст обрезанраздел «Крупный системный шрифт» выше
Нажатие без откликакнопка не гаснет под пальцем и не срывается«Нажатие — это автомат, а не onClick»
Свой диалог вместо системногочужие кнопки и чужой порядок в подтверждении«Modal и свой Alert вместо пустышки»
Поле под клавиатуройвес не видно, пока его вводишь«Клавиатура и безопасные зоны…»
Кнопка под домашней полоскойпалец задевает жест системы«Клавиатура и безопасные зоны…»
Спиннер на весь экрандневник уже в телефоне, ждать нечего«Цикл синхронизации: отправить, забрать, не задвоить»

На этом «Подход» собран целиком: он пишется в зале без сети, синхронизируется с партнёром, отдаёт дневник владельцу файлом, читается голосом и не разваливается от крупного шрифта. Осталось выпустить его — сборкой, магазином и первым обновлением по воздуху; этим последняя статья главы и занимается.

  • Голос ходит по узлам доступности, а не по строкам экрана: три Text подряд — три остановки, а «82,5» без единиц не значит ничего. accessible склеивает поддерево в одну остановку и заканчивает обход, поэтому группируют текст, а нажимаемое оставляют снаружи и подписывают отдельно.
  • В react-native-web — а это и веб-версия «Подхода» — доезжают accessibilityLabel, accessibilityRole и все aria-*, а accessibilityState, accessibilityHint и accessibilityValue не доезжают, и компилятор об этом молчит. Поэтому подпись и роль пишутся одним пропсом, состояние — двумя.
  • То, что меняется само, надо объявлять: индикатор «ждут отправки: 2 → сохранено» глазами видно, голосом — нет. В вебе это живая область (aria-live), на телефоне — announceForAccessibility, и объявляются итоги, а не шаги.
  • Мишень под палец — от 44 точек. hitSlop расширяет зону, не трогая рисунок, но не выходит за границы родителя: в строке высотой 32 запас дал 44 на 32, и нажатие снизу промахнулось. Чинит не запас, а minHeight: 44 у строки.
  • Системный шрифт растёт до полутора, двух и трёх раз: строке, которой хватало 55 точек, нужно 137. minHeight вместо height, allowFontScaling не выключать, maxFontSizeMultiplier — только там, где раскладка правда ломается, и numberOfLines с многоточием.
  • Проверить озвучку в предпросмотре нечем: isScreenReaderEnabled отвечает true всем, announceForAccessibility пуст, getFontScale() всегда 1. Настоящая проверка — на устройстве: крупный шрифт одной командой, TalkBack и VoiceOver свайпами, мишени большим пальцем стоя.

1/4Всю строку ленты обернули в View с accessible и подписью «жим, 82,5 килограмма, 5 повторов». Что станет с крестиком «удалить подход» внутри неё?

2/4Переключателю единиц написали accessibilityRole="switch" и accessibilityState={{ checked: true }}. Что окажется в разметке веб-версии?

3/4Крестику 24 на 24 в строке высотой 32 дали hitSlop={10}. Какая мишень получится?

4/4В веб-версии написали if (await AccessibilityInfo.isScreenReaderEnabled()) и показали в этой ветке упрощённый экран. Кто его увидит?

Кнопка, которой нет в озвучке

Экран отдыха: сколько осталось, что дальше и кнопка «Пропустить». Чтобы голос не читал время по цифрам, весь экран обернули в один доступный узел с человеческой подписью. Посмотрите, что осталось от озвучки, и почините так, чтобы «Пропустить» в неё вернулось, а две строки текста остались одной остановкой.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Группировать надо текст, а не весь экран: доступный узел заканчивает обход, и кнопка внутри него для голоса не существует.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится голос: Отдых, осталось 45 секунд / Пропустить отдых, кнопка — две остановки вместо одной. Ловушка тут в том, что исходный вариант не выглядит поломкой: экран цел, подпись правильная, а отдых нельзя пропустить, не глядя на телефон.

Мишень, которую не поймать

Тот же крестик в строке ленты, тот же запас hitSlop 10 — и мишень, которой не хватает до 44 на 44. Палец, метящий чуть ниже строки, не попадает. Добейтесь мишени 44 на 44, не увеличивая сам крестик: рисунок строки менять нельзя, крестик остаётся 24 на 24.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Запас упирается в родителя, поэтому расти должен родитель:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строка становится 44 точки высотой, крестик встаёт по центру, и вывод превращается в мишень: 44×44 | нужно 44×44 и палец 332,36 попал: true. В стилях это minHeight: 44, а не height: тогда та же строка переживёт и крупный системный шрифт.

Чего делать не стоит: увеличивать hitSlop до 20. Зона по вертикали от этого не вырастет ни на точку — она всё так же обрезана строкой, — зато по горизонтали налезет на соседнюю кнопку, и часть нажатий уйдёт не туда.

Переключатель, который молчит о себе

Переключатель единиц с экрана настроек: роль есть, подпись есть, состояние задано через accessibilityState — как советует документация React Native. Посмотрите, что из этого доехало до разметки веб-версии, и почините, не потеряв accessibilityState для телефона.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Состояние пишется двумя пропсами: один понимает телефон, другой — браузер.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тогда печатается роль: switch | подпись: Фунты | состояние: true. Оба пропса берут одно и то же значение из состояния — иначе однажды разъедутся: рисунок скажет «фунты», а голос — «выключено».