Нажатие — это автомат, а не onClick
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье кнопка «Записать» звала submit из onPress, а вопрос, что происходит между касанием и onPress, остался на потом. В части IV такого вопроса не было: onClick присылает браузер, пришёл — зовём. На телефоне «нажали» не присылает никто. Система сообщает только, что палец коснулся, двигается и отпустил, а нажатие из этого выводит сам React Native — автомат в классе Pressability, на котором стоит Pressable. Подсмотреть его в предпросмотре не выйдет: там onPress — обычный click. Поэтому соберём автомат сами, из пяти состояний, подключим к пальцу и увидим, откуда берётся pressed, почему нажатие срывается, если палец уехал, и чем click отличается от настоящего отпускания. Этаж — React Native: своего нажатия Expo не добавляет.
В статье «Первый экран: тот же React, другие элементы» мы заметили, что от fireEvent.press не срабатывают ни onPressIn, ни onPressOut, а pressed ни на миг не становится true. Теперь зайдём с другой стороны — пальцем. В подложке появился помощник finger: down(testID) касается центра элемента, move(dx, dy) сдвигает палец от точки касания, up() отпускает. Шлёт он события мыши, а система ответчика react-native-web слушает именно их, так что для Pressable это настоящее касание. Прогноз: сколько раз напечатается onPress — от щелчка и от касания с отпусканием?
onPress — один раз, от щелчка, и без onPressIn и onPressOut вокруг. А касание с отпусканием прошло весь путь — onPressIn, подсветка, onPressOut, — но нажатием не стало. Отпускание в react-native-web ничего не решает: onPress висит на браузерном click, а после синтетических mousedown и mouseup браузер click не присылает. Мы проверили и настоящей мышью, через протокол отладки Chrome: там click приходит, и onPress печатается после onPressOut, последним.Автор react-native-web расхождения с react-native не скрывает. Над обработчиком onClick в его PressResponder стоит комментарий, что здесь поведение «diverges from react-native in 3 significant ways» — расходится с react-native в трёх важных местах. Если пересказать: onPress не связан с системой ответчика и зовётся по click, поэтому может прийти без onPressIn и onPressOut; зовётся он только у ближайшего к цели щелчка предка с таким же автоматом; и nativeEvent у него — событие мыши, а не касания. Вот чем два автомата отличаются по исходникам:
Телефон: Pressability | Предпросмотр: PressResponder | |
|---|---|---|
| Кто решает, что это нажатие | автомат: отпустили в пределах зоны | браузер: пришёл click |
| Состояний | семь и ERROR | четыре и ERROR |
| Палец уехал за край | подсветка гаснет, нажатия не будет | такого сигнала нет: где палец, автомату всё равно |
| Задержка подсветки | 0 мс, меняет unstable_pressDelay | 50 мс |
| Долгое нажатие | 500 мс от касания | 500 мс от касания: 50 задержки и 450 |
hitSlop, pressRetentionOffset | расширяют зону | не разбираются |
Отсюда стена этой статьи. Как нажатие устроено на телефоне, у Pressable в предпросмотре не подсмотреть: половины телефонного автомата здесь нет, а решающий шаг отдан браузеру. Остаётся собрать автомат самим — по чертежу из исходника react-native, файла Pressability.js, — и подключить его к тому же пальцу.
Нажатие — не событие, а история: палец коснулся, подержал, может быть, уехал и вернулся, отпустил. Автомат помнит, в какой точке этой истории он сейчас, и на каждый сигнал отвечает переходом. Состояний у нашего пять:
| Состояние | Что видит человек | В Pressability |
|---|---|---|
idle | кнопки никто не касается | NOT_RESPONDER |
waiting | палец коснулся, но задержка ещё идёт — подсветки нет | RESPONDER_INACTIVE_PRESS_IN |
pressed | кнопка подсвечена: отпустить сейчас — значит нажать | RESPONDER_ACTIVE_PRESS_IN |
out | палец уехал за край: подсветка погасла, отпускание нажатием не станет | RESPONDER_ACTIVE_PRESS_OUT |
long | держат дольше полсекунды — долгое нажатие | RESPONDER_ACTIVE_LONG_PRESS_IN |
Сигналов семь, и у каждого есть двойник в Pressability: grant — касание досталось кнопке (RESPONDER_GRANT), delay — задержка истекла (DELAY), leave и enter — палец покинул зону и вернулся (LEAVE_PRESS_RECT, ENTER_PRESS_RECT), long — вышло время долгого нажатия (LONG_PRESS_DETECTED), release — отпустил (RESPONDER_RELEASE), terminate — касание забрали (RESPONDER_TERMINATED). Откуда они приходят, автомату всё равно, это следующий раздел.
Сам автомат — таблица переходов и три правила: подсветка зажглась — onPressIn, погасла — onPressOut, отпустили в «нажатом» состоянии — onPress. Метод configure меняет обработчики, не трогая состояние: такой же есть у Pressability, и в хуке он нам понадобится. Прогноз для шести историй: какие обработчики сработают в каждой?
Первые две истории неотличимы. «Быстрый тап» отпустили раньше, чем истекла задержка, и автомат, как и Pressability, подсветил кнопку и тут же погасил — иначе короткое касание осталось бы без всякого отклика. «Уехал» и «список поехал» различаются сигналом — release против terminate, — а итог у них один: onPress нет. Решает не то, где палец коснулся, а то, в каком состоянии его отпустили: из out отпускание нажатием не бывает.
«Держал» отличается от «тапа» одним сигналом long. onLongPress срабатывает, пока палец ещё на кнопке, а отпускание после него нажатием уже не считается — но только потому, что onLongPress кто-то слушает. Уберите его из configure, и та же история кончится PRESS: в Pressability обычное нажатие отменяется долгим по тому же условию.
Сигнал, которого нет в строке состояния, наш автомат молча пропускает: у out нет delay, и задержка, истёкшая, пока палец за краем, ничего не зажжёт. Pressability в таком месте строже. Лишние клетки его таблицы ведут в состояние ERROR, а приём такого сигнала кончается проверкой invariant с сообщением «Pressability: Invalid signal» — для него это ошибка в коде вокруг автомата, а не в жесте.
Настоящих состояний семь. Два лишних — «уехал раньше конца задержки» и «уехал после долгого нажатия». Первое у нас сразу становится out, второго нет вовсе: его вы добавите в последней задаче.
Сигналы Pressability берёт у системы ответчика — механизма React Native, который решает, какому View достанется касание. Как именно он решает, разбирает статья «Жест как разговор: собираем систему ответчика» в главе 8. Здесь хватит того, что у любого View есть пропсы-входы, и Pressable просто раздаёт их своему View: onStartShouldSetResponder — «хочу это касание», onResponderGrant — досталось (наш grant), onResponderMove — палец двигается, onResponderRelease — отпустил (release), onResponderTerminationRequest — касание просят отдать, onResponderTerminate — забрали (terminate). react-native-web исполняет ту же систему на событиях мыши и касания браузера, поэтому finger работает и с нашей кнопкой.
Остальные сигналы делаем сами. delay и long — таймеры от касания: 50 мс, как в предпросмотре, и 500 мс, как у Pressability. leave и enter — сравнение пальца с зоной. Размер кнопки спрашиваем у measure() — того нативного элемента под ref, что в статье «Первый экран», — и расширяем, как Pressability по умолчанию: на 20 точек вбок и вверх и на 30 вниз. Ответ measure() приходит колбэком, и пока его нет, движение пальца сравнивать не с чем. Pressability в этом месте тоже просто пропускает сигнал.
Состояние pressed — обычный useState внутри хука, и меняют его onPressIn и onPressOut автомата. Сам автомат живёт всю жизнь кнопки: он лежит в useState с ленивым начальным значением, а обработчики получает свежими после каждой отрисовки через configure. Так устроен и usePressability в react-native: экземпляр Pressability создаётся один раз, а configure зовётся из эффекта (useInsertionEffect) при каждой смене настроек. Автомат из прошлого раздела лежит в подложке. Прогноз: касание с отпусканием, потом щелчок — что напечатается?
Касание с отпусканием дали onPress — ровно то, чего не дал Pressable в первом разделе: наш автомат решает по отпусканию, как телефон. А щелчок не дал ничего: у нашего View нет onClick, а для ответчика click — не событие вовсе. onPress напечатался раньше последней отрисовки, потому что setPressed(false) и вызов onPress случились в одном обработчике отпускания, а отрисовка — после него, одной пачкой, как в части IV.
Теперь понятно, откуда берётся pressed в функции style у настоящего Pressable. В Pressable.js react-native 0.86 это такой же useState, и меняют его onPressIn и onPressOut, которые Pressable отдаёт в Pressability, — с одной оговоркой: только если style или children заданы функцией (переменная shouldUpdatePressed). Иначе pressed никто не читает, и лишняя отрисовка на каждое касание не нужна. В react-native-web такой оговорки нет, его Pressable меняет pressed всегда.
onPressIn и onPressOut наш хук забирает себе: из них получается pressed. Настоящий Pressable делает и то и другое — меняет pressed и зовёт ваши обработчики, если вы их передали.
В зале жмут «Записать», передумывают и уводят палец. Отпустят над кнопкой — подход запишется, уведут дальше — нет. Где проходит это «дальше»? Не по краю кнопки: палец толще пикселя и дрожит, поэтому Pressability прощает выход за край — по умолчанию на 20 точек вбок и вверх и на 30 вниз (DEFAULT_PRESS_RECT_OFFSETS). Хук из прошлого раздела теперь в подложке. Кнопка высотой 40, палец касается её центра. Прогноз: на каком сдвиге кнопка погаснет, загорится ли снова и будет ли onPress?
На 25 ниже края кнопка горит: это ещё зона удержания, и enter в состоянии pressed автомат пропустил — даже отрисовки не было. На 40 ниже погасла: leave перевёл автомат в out. Палец вернули — enter, снова pressed, и отпускание стало нажатием. Так кнопка ведёт себя на телефоне. В описании pressRetentionOffset у TouchableWithoutFeedback это даже предлагают проверить руками: увести палец с кнопки и вернуть, несколько раз подряд.
Размер зоны задают два пропса Pressable. pressRetentionOffset — насколько далеко палец может уйти, не погасив кнопку; наши 20, 20, 20 и 30 — его умолчание. hitSlop — насколько дальше края касание может начаться. Это решает нативная сторона, когда ищет, кому отдать касание, а Pressability прибавляет hitSlop ещё и к зоне удержания. Крестику «удалить подход» размером 24 точки hitSlop={10} даёт мишень 44 на 44, не меняя рисунка; типы react-native советуют мишень не меньше 30–40 точек. Там же оговорка: зона касания не выходит за границы родителя.
Тот же палец и настоящий Pressable. Зону удержания мы ему даже обнулили: pressRetentionOffset={0}. Прогноз: когда погаснет кнопка и будет ли onPress?
Палец ушёл на 200 точек, а кнопка горит до самого отпускания: в автомате react-native-web нет ни leave, ни enter, а pressRetentionOffset и hitSlop его Pressable не разбирает вовсе. onPress нет, потому что не было щелчка. Настоящая мышь, через протокол отладки Chrome, гасит кнопку раньше, но и тут зона ни при чём: зажатый курсор, уезжая, выделяет часть надписи «Записать», и на selectionchange система ответчика react-native-web отнимает касание. onPressOut приходит, пока курсор ещё в стороне, onPress после отпускания нет. Так что зону удержания на экране примера не проверить: только нашим автоматом или на телефоне.
Долгое нажатие в «Подходе» висит на строке подхода: коротко — открыть подход, долго — меню «изменить или удалить». Прогноз: палец держат 700 мс и отпускают. Что напечатается и в каком порядке?
Через 500 мс от касания таймер прислал long, и меню открылось, пока палец ещё на экране: человек видит отклик, не отпуская. Отпускание после этого нажатием не стало — автомат был в long, а onLongPress у строки есть. Подсветка при этом горела всё удержание и погасла только на отпускании. Одно правило Pressability наш автомат пропускает: если за время удержания палец сдвинулся больше чем на 10 точек (longPressDeactivationDistance), таймер долгого нажатия отменяется — человек уже не держит, а начинает жест.
Второй способ сорвать нажатие — не отпустить палец в стороне, а лишиться касания. Строка подхода стоит в списке. Палец коснулся, строка подсветилась — и список поехал. Прогноз: что будет с подсветкой и придёт ли onPress после отпускания?
Прокрутка забрала касание. react-native-web получил scroll у предка кнопки и спросил её onResponderTerminationRequest; кнопка согласилась и получила onResponderTerminate. Автомат ушёл в idle, подсветка погасла, а отпускание кнопке уже не досталось. На телефоне так же: список, поехавший под пальцем, просит касание себе. Pressable соглашается, пока пропс cancelable не false, — в Pressability ответ так и записан: cancelable ?? true. А бывает, что касание забирают, не спрашивая: в описании onResponderTerminate в типах react-native пример — центр управления и шторка уведомлений на iOS.
Здесь же видно, зачем подсветке задержка. Если палец поехал в первые 50 мс, автомат уйдёт из waiting прямо в idle, и строка не вспыхнет: в waiting подсветки ещё нет. У Pressable на телефоне задержка по умолчанию нулевая, и включает её пропс unstable_pressDelay — приставка в имени предупреждает, что свойство ещё может поменяться.
Вернёмся к форме подхода. FormField, validateSet, fieldKinds и parseAmount приехали в подложку из статьи «Форма без библиотек», хук с автоматом — из этой. «Записать» теперь на usePress. Прогноз: сначала палец касается кнопки, уезжает на 90 точек вниз и отпускает, потом нажимает как следует. Сколько раз запишется подход и с какими повторами?
Один раз, с пятью повторами. Первое касание кончилось в out, и передумавший человек ничего не записал. Повторы свежие, хотя submit читает values из замыкания своей отрисовки: configure после каждой отрисовки отдаёт автомату новый submit. Настрой мы автомат один раз, при создании, он звал бы submit первой отрисовки с пустыми повторами и напечатал бы «не записано» — это снимок из статьи части IV «Состояние — снимок, а не переменная».
Чего наш автомат не умеет, а Pressability умеет:
| Что | У Pressability | У нас |
|---|---|---|
| Состояния | семь и ERROR | пять |
| Минимальная подсветка | onPressOut не раньше 130 мс после onPressIn | нет: у быстрого тапа подсветка зажигается и гаснет в одной пачке и на экран не попадает |
| Сдвиг во время удержания | больше 10 точек — долгого нажатия не будет | нет |
| Кнопка ушла с экрана | reset() отменяет таймеры | таймеры доживают своё |
| Отклик | звук касания на Android; волну android_ripple добавляет уже Pressable | нет |
disabled, cancelable | пропсы | первые две задачи |
Хук, автомат и палец переезжают в подложку следующей статьи. Там долгое нажатие на строке подхода откроет «Удалить подход?» — диалог, который придётся собрать самим на Modal: Alert.alert в react-native-web — пустой метод.
- На телефоне «нажали» не присылает никто: система сообщает касание, движение и отпускание, а нажатие из них выводит автомат
Pressability, на котором стоитPressable. - В предпросмотре
onPress— браузерный click:fireEvent.pressдаётonPressбезonPressInиonPressOut, а касание с отпусканием — наоборот. Проверять нажатие по экрану примера нельзя. - Автомат — таблица переходов:
idle,waiting,pressed,out,longи сигналыgrant,delay,leave,enter,long,release,terminate.onPress— отпускание в «нажатом» состоянии, а не касание кнопки. - Сигналы автомату шлёт система ответчика через пропсы
View, таймеры иmeasure().pressed—useStateвнутри кнопки, его меняютonPressInиonPressOut;Pressableв react-native 0.86 делает это, только еслиstyleилиchildren— функция. - Нажатие срывается, если палец ушёл за зону удержания (по умолчанию 20 точек вбок и вверх и 30 вниз; её меняют
pressRetentionOffsetиhitSlop) или касание забрал список (onResponderTerminate, пропсcancelable). Вернувшийся палец снова зажигает кнопку. - Долгое нажатие приходит через 500 мс, пока палец ещё на экране, и отменяет обычное — если
onLongPressкто-то слушает.
1/4В предпросмотре вызвали fireEvent.press("save") у Pressable с обработчиками onPressIn, onPressOut и onPress. Какие сработают?
2/4На телефоне палец коснулся «Записать», уехал на 60 точек ниже её края и там отпустил. Что получит кнопка при умолчаниях Pressable?
3/4Откуда в style={({ pressed }) => …} у Pressable берётся pressed?
4/4Строка подхода стоит в прокручиваемом списке. Палец коснулся её, строка подсветилась, и список поехал. Что дальше?
«Завершить», пока подходов нет
Кнопка «Завершить тренировку» сделана на usePress из подложки. Пока подходов ноль, палец не должен её нажимать, а хук про disabled ничего не знает. Добейтесь этого, не трогая хук, — одним пропсом у View кнопки. Подсказка: подумайте, какой вход ответчика у Pressability отвечает за disabled.
Касание сперва спрашивает у View, хочет ли он его, — это onStartShouldSetResponder. Отказался — не будет ни grant, ни автомата, ни нажатия. Так disabled и устроен в Pressability: этот вход у него возвращает !disabled.
Вывод — одна строка: завершена, подходов: 1. Порядок пропсов важен: встань onStartShouldSetResponder до {...finish.responder}, его перекрыл бы хуковый () => true. У настоящего Pressable disabled делает больше: он ещё и сообщает скринридеру, что кнопка недоступна, а вход ответчика этого не умеет.
Удержание, которое прокрутка не отнимет
На экране отдыха есть кнопка «Держите, чтобы сбросить отдых» — на usePress с одним onLongPress. Экран прокручивается, и стоит пальцу дрогнуть, как прокрутка забирает касание и сброс не случается. Сделайте так, чтобы прокрутка не могла отнять у этой кнопки начатое удержание.
Прокрутка не отбирает касание силой, а просит: зовёт у текущего ответчика onResponderTerminationRequest. Ответ false — отказ, и касание остаётся у кнопки.
Вывод: pressed: false, pressed: true, отдых сброшен, pressed: false. У Pressable это пропс cancelable={false}: в Pressability этот вход возвращает cancelable ?? true. Ставят его там, где удержание важнее прокрутки, — не на обычные кнопки в списке: там отданное прокрутке касание как раз и спасает от случайных нажатий. А касание, которое система забирает не спрашивая, отказ не удержит.
Долгое нажатие уехало за край
У нашего автомата нет двух состояний Pressability, и отсутствие одного из них заметно. После долгого нажатия палец можно увести с кнопки, а подсветка горит: long пропускает leave. Добавьте шестое состояние longOut: уход после долгого нажатия гасит подсветку, возвращение снова её зажигает, а отпускание в longOut нажатием не становится.
Нужны три правки: состояние в типе, две строки таблицы и одно условие в правиле отпускания.
Вывод: leave → longOut: out, enter → long: in, leave → longOut: out, release → idle: —. Функцию active трогать не пришлось: longOut не подсвечен, и переходы в него и из него сами дают onPressOut и onPressIn. Добавить состояние в тип, забыв про таблицу, не выйдет: Record<PressState, …> требует строку для каждого состояния, и компилятор скажет, какой не хватает. Это и есть RESPONDER_ACTIVE_LONG_PRESS_OUT из Pressability; седьмое, RESPONDER_INACTIVE_PRESS_OUT, отличает уход до конца задержки — у нас он сразу ведёт в out.