Доступность и мелочи, из-за которых приложение кажется чужим
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
«Подход» умеет всё, ради чего затевался: пишется в зале без сети, синхронизируется, отдаёт дневник владельцу файлом. Осталось то, чего не видно ни в одном прогоне, — и в зале оно решает не меньше, чем протокол синхронизации. Телефон там лежит на скамье, руки заняты и в перчатках, между подходами на экран жмут почти вслепую, а кто-то не смотрит на него вовсе. Эта статья про три такие вещи: строка «жим 82,5 5» звучит совсем не так, как выглядит; крестик в 24 точки промахивается чаще, чем кажется; системный шрифт растёт, а высота строки — нет. И сразу честно про среду: настоящего голоса в предпросмотре нет, поэтому обход по узлам доступности мы соберём миниатюрой — зато разметка, которую читает скринридер, здесь настоящая, и она же едет во второе устройство «Подхода», веб-версию.
Строка ленты собрана как в любом приложении: View, внутри три Text — упражнение, вес, повторы, — а справа крестик «удалить подход». Глазами это одна строка. Голосом — не одна: скринридер ходит не по тому, что нарисовано, а по узлам доступности, и каждый такой узел — отдельная остановка, на которой голос называет подпись, роль и состояние.
Правил, из которых складывается обход, три, и все они записаны в документации React Native и в типах. Первое: Text и всё нажимаемое — узлы доступности сами по себе, обычный View — нет. Второе: пропс accessible делает узлом сам View, и тогда обход внутрь не идёт вовсе — поддерево склеивается в одну остановку. Третье: если подписи нет, она собирается сама — обходом всех потомков со склейкой текста через пробел, как сказано про accessibilityLabel в ViewAccessibility.d.ts.
Соберём эти три правила в два десятка строк и прогоним через них три варианта одной и той же строки ленты: как она написана сейчас; вся строка одним доступным узлом с подписью; группа с текстом плюс кнопка снаружи. Прогноз: сколько остановок выйдет в каждом случае и что голос скажет на каждой?
Первый вариант — четыре остановки, и ни одна не годится. «82,5» — чего? «5» — чего? Голос читает ячейки таблицы, из которой убрали шапку. А крестик он называет «×», потому что подписи нет, и собрать её не из чего: внутри тот же символ.
Второй вариант выглядит решением и им не является. Остановка одна, текст правильный — а кнопки «Удалить подход» в озвучке нет вовсе. Доступный узел заканчивает обход: всё, что внутри, для голоса перестаёт существовать, включая нажимаемое. На телефоне это значит ровно то, как звучит: удалить подход, не глядя на экран, станет нельзя.
Третий вариант — то, что нужно: две остановки, «жим, 82,5 килограмма, 5 повторов» и «Удалить подход, кнопка». Отсюда правило, к которому сводится почти вся работа над озвучкой списка: группируют текст, нажимаемое оставляют снаружи и подписывают отдельно. Подпись при этом пишется словами, которые человек говорит вслух: «82,5 килограмма», а не «82.5 kg» и не «вес двоеточие».
Это модель, а не голос. VoiceOver в песочницу не поставить, обход здесь наш собственный и по трём правилам из документации. Он годится, чтобы увидеть, где остановок слишком много, а где они пропали. Он не скажет, каково это — слушать ваш экран полчаса подряд; для такого есть телефон, и им статья заканчивается.
Миниатюра показала, что должно произойти. Теперь — что происходит на самом деле, в той среде, где выполняются примеры. Это не только про предпросмотр: react-native-web и есть веб-версия «Подхода», то самое второе устройство из главы 7, и разметка ниже — ровно та, которую на компьютере получит настоящий скринридер.
Пять узлов, и все написаны так, как советуют их писать: заголовок экрана с ролью header; крестик с подписью, подсказкой и запасом hitSlop; переключатель единиц с ролью switch и состоянием через accessibilityState; он же, но с aria-checked; и декоративная иконка, спрятанная от голоса через importantForAccessibility. Прогноз: что из этого доедет до разметки, а что исчезнет по дороге?
Роль доехала, и даже щедрее, чем просили: 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.
| Что написали | Предпросмотр и веб-версия | Телефон |
|---|---|---|
accessibilityLabel | aria-label | подпись, которую называет голос |
accessibilityRole | role, а иногда и сам тег: h1, button | роль: «кнопка», «заголовок», «переключатель» |
accessibilityState | не доезжает | состояние: «включено», «выбрано», «недоступно» |
accessibilityHint | не доезжает | подсказка после паузы, iOS и Android |
accessibilityValue | не доезжает | значение: «45 из 90» |
aria-checked и прочие aria-* | доезжают как есть | переводятся в accessibilityState |
hitSlop, importantForAccessibility | не разбираются | зона касания и «спрятать от голоса» |
Отсюда правило для проекта, который живёт на трёх платформах: подпись и роль пишутся одним пропсом, состояние — двумя. Подпись с ролью работают везде; состояние на вебе понимают только aria-*, а на телефоне aria-checked — псевдоним accessibilityState, и react-native сворачивает его в состояние сам. Написать оба всё равно дешевле, чем однажды выпустить переключатель, который молчит о себе. Строка ленты после этого выглядит так:
В листинге у группы стоит role="group", и это не украшение. Подпись доезжает в разметку и без роли — но ARIA запрещает имя у узла без роли (обычный <div> для неё «generic»), поэтому браузер такое имя просто не отдаст. На телефоне тот же accessible склеит узел и без всяких ролей. Прогноз: что окажется в разметке у двух одинаковых групп, одна из которых с ролью?
Подпись стоит у обоих, роль — у второго, и разница в том, кто эту подпись прочтёт. Это и есть цена одинакового кода на трёх платформах: accessible — слово из мира телефона, роль — слово из мира браузера, и «Подход» пишет оба.
Состояние «недоступно» — единственное, о котором не надо рассказывать дважды: disabled у Pressable сам доезжает и до разметки (aria-disabled, tabindex="-1"), и до телефона. Это та же кнопка из «Первого экрана», у которой выключенность и нажатие живут в одном пропсе.
В приложении на запросах экран меняется после нажатия: нажали — подождали — увидели. В local-first половина изменений приходит сама: sync() запускается при возврате из фона, чужая правка приезжает с курсора, индикатор из «Цикла синхронизации» переходит из «ждут отправки: 2» в «сохранено». Глазами это мигнувшая строка. Голосом — ничего: скринридер читает то, на чём стоит фокус, а фокус никуда не двигался.
Способов рассказать два, и они не взаимозаменяемы. Живая область — accessibilityLiveRegion (пропс Android, в вебе это aria-live): система сама зачитывает изменившийся текст. Прямое объявление — AccessibilityInfo.announceForAccessibility(строка): приложение произносит фразу, никак не связанную с фокусом. Прогноз: что покажет разметка индикатора до и после нажатия на «Синхронизировать» и что вернёт объявление?
Текст индикатора сменился, 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?
Просили 44 на 44, получили 44 на 32: по горизонтали запас лёг целиком, а по вертикали его съела строка — зона не может быть выше родителя. Из пяти нажатий одно попало бы и без запаса, ещё два спас hitSlop, а последние два — промах. И промах снизу (332,36) — тот самый случай, ради которого запас и ставили: он не сработал.
Отсюда вывод, который экономит часы: hitSlop не заменяет размер строки. Пока у строки height: 32, любой запас упирается в её край. Строка с minHeight: 44 — и та же кнопка получает полную мишень (последняя строка вывода). Заодно решается и вторая беда, о которой следующий раздел: minHeight вместо height переживает крупный системный шрифт.
Запас есть и у второго края нажатия: pressRetentionOffset — насколько далеко палец может уехать, не погасив кнопку («Нажатие — это автомат»), и hitSlop прибавляется к нему тоже. А если по строке ещё и свайп («Жест как разговор»), зоны кнопки и жеста стоит разводить руками: спор за касание выигрывает не тот, кто ближе, а тот, кто первым стал ответчиком.
Третья вещь приходит из системных настроек: человек ставит крупный шрифт — и весь текст в приложении растёт вместе с ним, потому что React Native масштабирует его сам, пока у Text не выключен allowFontScaling. Насколько именно — сообщает PixelRatio.getFontScale(). Прогноз: что он ответит в предпросмотре и что станет со строкой ленты, у которой в стилях height: 60?
Единица — и так будет всегда: в 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 — вопросы к системе и команда «сказать вслух». Прогноз: что он ответит здесь, в предпросмотре?
true» — не про ваш компьютер: в react-native-web этот метод буквально возвращает готовое true всем и всегда. Поэтому ветка «если включён скринридер, покажем иначе» в веб-версии включится у каждого — и это лучший довод не писать таких веток вовсе, а делать экран пригодным сразу. announceForAccessibility — пустой метод. Честный ответ здесь один, последний: «меньше движения» читает prefers-reduced-motion браузера через matchMedia — так же, как в части II выбирали плавную или мгновенную прокрутку. Поэтому пример и печатает тип, а не значение: у вас оно своё.Проверка переезжает на устройство, и это опыты с выключателями, а не «посмотреть глазами» — как авиарежим и убийство процесса из «Что в подложке было ненастоящим». Первый опыт задаётся одной командой на каждую платформу:
Второй опыт — голос. 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()) и показали в этой ветке упрощённый экран. Кто его увидит?
Кнопка, которой нет в озвучке
Экран отдыха: сколько осталось, что дальше и кнопка «Пропустить». Чтобы голос не читал время по цифрам, весь экран обернули в один доступный узел с человеческой подписью. Посмотрите, что осталось от озвучки, и почините так, чтобы «Пропустить» в неё вернулось, а две строки текста остались одной остановкой.
Группировать надо текст, а не весь экран: доступный узел заканчивает обход, и кнопка внутри него для голоса не существует.
Вывод становится голос: Отдых, осталось 45 секунд / Пропустить отдых, кнопка — две остановки вместо одной. Ловушка тут в том, что исходный вариант не выглядит поломкой: экран цел, подпись правильная, а отдых нельзя пропустить, не глядя на телефон.
Мишень, которую не поймать
Тот же крестик в строке ленты, тот же запас hitSlop 10 — и мишень, которой не хватает до 44 на 44. Палец, метящий чуть ниже строки, не попадает. Добейтесь мишени 44 на 44, не увеличивая сам крестик: рисунок строки менять нельзя, крестик остаётся 24 на 24.
Запас упирается в родителя, поэтому расти должен родитель:
Строка становится 44 точки высотой, крестик встаёт по центру, и вывод превращается в мишень: 44×44 | нужно 44×44 и палец 332,36 попал: true. В стилях это minHeight: 44, а не height: тогда та же строка переживёт и крупный системный шрифт.
Чего делать не стоит: увеличивать hitSlop до 20. Зона по вертикали от этого не вырастет ни на точку — она всё так же обрезана строкой, — зато по горизонтали налезет на соседнюю кнопку, и часть нажатий уйдёт не туда.
Переключатель, который молчит о себе
Переключатель единиц с экрана настроек: роль есть, подпись есть, состояние задано через accessibilityState — как советует документация React Native. Посмотрите, что из этого доехало до разметки веб-версии, и почините, не потеряв accessibilityState для телефона.
Состояние пишется двумя пропсами: один понимает телефон, другой — браузер.
Тогда печатается роль: switch | подпись: Фунты | состояние: true. Оба пропса берут одно и то же значение из состояния — иначе однажды разъедутся: рисунок скажет «фунты», а голос — «выключено».