Жест как разговор: собираем систему ответчика
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Свайп влево по строке подхода — и подход удалён: так «Подход» ведёт себя как приложение, а не как сайт. В части IV всё решало всплытие: событие идёт от кнопки к предкам, и его слышит каждый обработчик по дороге. Здесь так нельзя. Крестик «удалить» лежит в строке, строка — в списке, и если одно касание услышат все трое, один сдвиг пальца нажмёт крестик, утащит строку и прокрутит список разом. Поэтому касание в React Native не рассылается, а достаётся одному виду — ответчику — и переходит к другому только по договорённости. Соберём этот договор сами — замок на дерево видов, — сверим его с react-native-web строка в строку, а потом сделаем на PanResponder свайп по строке подхода. Этаж — React Native: своего жеста Expo не добавляет.
В статье «Нажатие — это автомат, а не onClick» система ответчика была видна со стороны одного View: onStartShouldSetResponder — «хочу это касание», onResponderGrant — «досталось». Теперь вложим три вида друг в друга — экран, строку подхода и крестик «удалить», — и пусть каждый хочет касание. Чтобы видеть, кого и о чём спрашивают, обработчики обёрнуты в loud: он печатает имя вида, имя обработчика и ответ. Палец из той же статьи снова в подложке: finger.down касается центра элемента с этим testID, move сдвигает палец от точки касания, up отпускает.
Крестика коснутся дважды: сначала как есть, потом подняв флажок, после которого строка отвечает «да» на onStartShouldSetResponderCapture. Прогноз: кого о чём спросят и кому достанется касание в первый раз и во второй?
Вопросы идут в два прохода. Первый — от корня к цели, с приставкой Capture: экран, строка, крестик. Никто не взял — начинается второй, от цели к корню, без приставки. Первым его слышит крестик и отвечает «да», и на этом обход кончается: экрана и строку об onStartShouldSetResponder даже не спросили. Во второй раз строка забрала касание ещё на первом проходе, и крестик не услышал ни одного вопроса.
Порядок проходов тот же, что у событий DOM с захватом и всплытием, — их разбирала статья части IV «Всплытие, отмена и остановка в React». Разное — то, что идёт по дереву. В DOM по пути идёт событие, и его получает каждый обработчик, пока кто-нибудь не остановит всплытие. Здесь по пути идёт вопрос, и первое «да» кончает обход: касание достаётся одному виду, и все его события — сдвиги и отпускание — дальше идут только этому виду. В исходнике react-native-web это названо «a single, global "interaction lock" on views» — один общий замок на виды.
| Вопрос | Когда задают | Порядок |
|---|---|---|
onStartShouldSetResponderCapture | палец коснулся | от корня к цели |
onStartShouldSetResponder | палец коснулся, а на первом проходе никто не взял | от цели к корню |
onMoveShouldSetResponderCapture | палец сдвинулся | от корня к цели |
onMoveShouldSetResponder | палец сдвинулся, а на первом проходе никто не взял | от цели к корню |
Из двух проходов следуют два правила. Без Capture касание берёт самый глубокий из желающих: предки уступают детям, и крестик внутри строки нажимается. С Capture предок забирает касание раньше детей. Так делает ScrollView в React Native, пока список едет по инерции: тап по движущемуся списку должен остановить его, а не нажать строку.
Вопрос Capture на спуске задают каждому виду на пути, пока никто выше не ответил «да», — даже тому, чей ответ заранее «нет». Поэтому в нём удобно отмечать начало касания: PanResponder обнуляет в нём своё состояние жеста, а строка в следующем разделе запомнит там точку касания.
Крестик взял касание, а палец поехал вбок: человек не нажимает, а утаскивает строку. Касание уже занято, и отдать его строке может только переговор. Строка хочет касание, если палец ушёл дальше 10 точек; точку касания она запоминает в вопросе Capture на спуске. Крестик на просьбу отдать соглашается. Под строкой лежит сосед, которому подойдёт любое движение, и третий сдвиг уведёт мышь вниз, прямо на него. loud теперь в подложке. Прогноз: на каком сдвиге касание сменит хозяина и что услышит сосед, когда окажется под указателем?
На 4 точках строка отказалась, на 18 — захотела, и начались переговоры. Крестика спросили onResponderTerminationRequest, он согласился и получил onResponderTerminate, а строка — onResponderGrant и сразу onResponderMove за то же самое движение. На третьем сдвиге мышь была уже над соседом, но вопросов не было: хозяин — строка, и движение пришло ей. Сосед не услышал ничего, хотя лежал под указателем и хотел любое движение, — а в DOM этот mousemove достался бы именно ему.
Почему сосед молчит — это главное правило замка. Когда хозяин есть, спрашивают не всю ветку под пальцем, а только общих предков пальца и хозяина, и самого хозяина из них вычёркивают. Сосед строке не предок, поэтому его нет среди спрашиваемых; общих предков с обработчиками у них здесь нет вовсе, и не спросили никого. Замок передаётся только вверх по дереву: список может отнять касание у строки, строка — у крестика, но не наоборот и не вбок. В описании системы в react-native-web это одна фраза: замок «can be transferred (only) to ancestors».
Хозяин может и не отдать. Замените у крестика () => true в onResponderTerminationRequest на () => false и запустите снова: вместо передачи придут крестик.onResponderTerminationRequest → false и строка.onResponderReject, и касание останется у крестика. На третьем сдвиге, уже над соседом, строку не спросят вовсе: она предок крестика, но не соседа. В статье про автомат нажатия этим отказом держалось удержание, которое прокрутка не отнимет; у Pressable он называется cancelable={false}.
Съехать пальцем на соседа можно только в предпросмотре. Мышиное событие браузер адресует элементу под курсором, а касание на телефоне приходит виду, где оно началось: iOS и Android запоминают его при касании. Там сосед станет целью, только если его коснётся второй палец, — и спросят тогда тоже лишь общих предков. Ещё одна тонкость предпросмотра: общего предка react-native-web ищет с ошибкой на единицу и, когда общий у веток только самый верхний вид с обработчиками, не спрашивает никого. У строки и соседа общего вида с обработчиками нет совсем, поэтому на этот вывод ошибка не влияет.
Соберём тот же договор без React и без браузера. Вид — узел с именем, родителем и обработчиками, названными как пропсы View. Касание — объект { dx, dy }, сдвиг от точки касания: вычитать координаты, как строка выше, здесь незачем. Замок хранит одного хозяина и умеет три вещи — down, move и up. Логика — в двух функциях. askers решает, кого спрашивать: без хозяина — всю ветку цели, с хозяином — общее начало двух веток без самого хозяина. negotiate задаёт вопросы в два прохода и ведёт переговоры. call печатает в том же формате, что loud.
Дерево и история те же, что в прошлом разделе: экран, строка с крестиком, сосед и сдвиги на 4, 18 и 30 точек, последний — над соседом. Сдвиг, как мышь в предпросмотре, адресуем виду под указателем. Прогноз: совпадёт ли вывод с react-native-web?
Совпал строка в строку, и с отказом крестика совпадёт тоже — проверьте. Выходит, всё, что показал react-native-web, держится на трёх решениях. Два прохода — это find по ветке от корня, потом по перевёрнутой, и первое «да» кончает поиск. Правило предков — это askers: пока хозяин есть, в список попадает только общее начало двух веток, и соседа в нём не бывает. Переговоры — это ветка с owner в negotiate: хозяин без onResponderTerminationRequest считается согласным, а его «нет» превращается в onResponderReject для претендента.
| Наш замок | react-native-web | React Native 0.86 |
|---|---|---|
branch, askers | getResponderPaths, getLowestCommonAncestor | поиск общего предка в ResponderEventPlugin |
два find в negotiate | findWantsResponder | двухфазный обход вопросов в том же плагине |
переговоры в negotiate | attemptTransfer | вопрос onResponderTerminationRequest в extractEvents |
owner | currentResponder | responderInst |
Настоящий плагин живёт в рендерере React Native: в 0.86 это объект ResponderEventPlugin в файле ReactFabric-dev.js. Скелет у него тот же: он считает глубину двух веток, выравнивает их и поднимается до совпадения, хозяина из вопросов вычёркивает, спрашивает в два прохода. Разный — порядок переговоров. Претенденту onResponderGrant там вызывают сразу, через executeDirectDispatch, и только потом спрашивают хозяина: ответ Grant — не пускать ли к касанию нативные виды — нужен плагину, чтобы сменить хозяина. Если хозяин отказал, претендент получает onResponderReject после уже полученного onResponderGrant и хозяином так и не становится. Отсюда правило для телефона: в onResponderGrant ничего необратимого, а начатое отменяют в onResponderReject.
Чего в замке нет: нескольких пальцев, событий прокрутки и выделения текста, которые тоже умеют отнимать касание, и отмены касания системой — о ней в последнем разделе. В таком виде замок лежит в подложке первой задачи.
Писать все эти обработчики для каждого жеста и самим вычитать координаты незачем: для этого есть PanResponder. Это не компонент и не отдельная система, а фабрика обработчиков ответчика. PanResponder.create принимает те же вопросы и уведомления, только с Pan в имени — onMoveShouldSetPanResponder, onPanResponderGrant, onPanResponderMove, — и отдаёт panHandlers, которые раскладывают на View пропсами. Прогноз: сколько обработчиков окажется в panHandlers, если задан один колбэк?
Тринадцать. Двенадцать из них — входы ответчика, те же пропсы View: четыре вопроса, Grant, Reject, Start, Move, End, Release, Terminate и TerminationRequest. Каждый зовёт ваш колбэк, если он есть; вопрос без колбэка отвечает «нет», просьба отдать без колбэка — «да». Тринадцатый, onClickCapture, есть только в react-native-web: после жеста он гасит браузерный click, чтобы кнопка под отпущенным пальцем не нажалась. В PanResponder React Native 0.86 ключей двенадцать — кликов на телефоне нет.
Вторым аргументом колбэки получают gestureState — то, ради чего PanResponder и берут. x0 и y0 — где был палец в момент захвата, moveX и moveY — где он сейчас, dx и dy — сколько прошёл, vx и vy — скорость в точках за миллисекунду, numberActiveTouches — сколько пальцев на экране. Всё это считается из истории касаний, которую ведёт сама система ответчика.
Свайп не должен забирать касание сразу: вдруг человек нажимает крестик или листает список. Поэтому строка берёт жест на движении, когда палец ушёл дальше 10 точек. Прогноз: какой dx придёт, когда палец будет на −40?
Палец на −40, а dx равен −25. Пока строка не была хозяином, dx копился от касания: в вопросе на −15 он был −15. А в момент захвата PanResponder его обнуляет и дальше считает от точки захвата:
Над полем dx в том же файле написано «accumulated distance of the gesture since the touch started», но ниже, в разборе полей, честнее: пока вид не хозяин, dx — только путь до порога. Для свайпа это значит, что строка, которой отдают dx как есть, отстаёт от пальца ровно на порог захвата; как это исправить — третья задача. И ещё одно: на −15 не пришло onPanResponderMove. Движение, на котором вид стал хозяином, PanResponder уже учёл в вопросе и второй раз его не присылает: onResponderMove сверяет время события с последним учтённым.
Соберём строку подхода, которую можно утащить влево. Под пальцем её ведёт Animated.Value в translateX — значение из прошлых статей главы, которое двигает вид без рендера экрана. Отпустили — пружина везёт строку назад или за край, и тогда подход удаляется. Под строкой лежит красная подложка: то, что открывается при свайпе. Решения записаны в колбэках:
| Колбэк | Решение |
|---|---|
onMoveShouldSetPanResponder | берёт только горизонтальный жест: сдвиг больше 10 точек, и по горизонтали больше, чем по вертикали; вертикальный остаётся списку |
onPanResponderGrant | останавливает пружину: строку могут схватить, пока она ещё едет назад |
onPanResponderMove | ставит сдвиг под палец, но не правее нуля: вправо строка не уезжает |
onPanResponderTerminationRequest | отвечает «нет»: начатый свайп прокрутка не отнимет — тот же отказ, что у крестика |
onPanResponderTerminate | касание отняли без спроса — пружина возвращает строку на место |
onPanResponderRelease | дальше трети ширины — за край и удалить, иначе назад |
В подложке — палец, строка var global = globalThis, без которой остановка пружины падает в предпросмотре, и checkNative из статьи «useNativeDriver: два потока»: сверим стиль строки со списком нативного драйвера. Прогноз: что скажет сверка и что станет с коротким и с длинным свайпом?
Как и во всей главе: пружина идёт кадрами, а кадры — только пока экран примера виден. Запускайте клавишей Enter из поля прогноза — экран стоит прямо над ним.
Короткий свайп вернулся, длинный увёз строку за край, и подход исчез из списка. «Отпустили на −40», хотя палец ушёл на 60, — те самые 20 точек, на которых случился захват. Сверка ответила «можно»: translateX в списке нативного драйвера есть, и пружины после отпускания на телефоне можно отдать нативу — setValue нативному значению не запрещён, он просто отправляет число в натив. Но главного это не меняет. Под пальцем строку ведёт не анимация, а setValue из колбэка движения, а колбэк выполняется в JS-потоке: там живёт вся система ответчика и весь PanResponder. Пока JS-поток занят, строка за пальцем не едет, какой бы флаг ни стоял у пружин. Здесь они с false: в предпросмотре true добавил бы только предупреждение. Как увезти в другой поток сам код жеста — следующая статья, про worklet-ы.
Вместо своего колбэка движение можно отдать Animated.event: он раскладывает аргументы колбэка по значениям. У PanResponder аргументы — событие и gestureState, поэтому карта — [null, { dx: x }]. Раз translateX нативному драйверу разрешён, хочется поставить useNativeDriver: true. Прогноз: сдвинется ли строка в предпросмотре?
Сдвинулась — с тем же предупреждением, что в прошлой статье: нативного модуля в предпросмотре нет, и Animated.event откатился на JS. На телефоне строка осталась бы на месте. Обработчик нативного Animated.event, вызванный из JS, только зовёт слушателей: так написан __getHandler в AnimatedEvent.js React Native 0.86. Раскладывать событие по значениям должен нативный код, а событие жеста PanResponder в нативном коде не рождается — его собирает JS. Поэтому у жеста на ответчике только useNativeDriver: false.
На телефоне иначе: Animated.event с useNativeDriver: true в колбэке PanResponder значения не двигает. В предпросмотре двигает — нативного драйвера там нет вовсе.
Замок в предпросмотре и на телефоне устроен одинаково: те же два прохода вопросов, то же правило предков, тот же PanResponder. Разное — порядок переговоров и всё вокруг замка. В предпросмотре react-native-web слушает на document события мыши и касаний браузера и сам ведёт переговоры в модуле ResponderSystem. На телефоне касания присылает нативный обработчик — на iOS RCTSurfaceTouchHandler, на Android JSTouchDispatcher, — а переговоры ведёт ResponderEventPlugin внутри рендерера.
| Предпросмотр: react-native-web | Телефон: React Native 0.86 | |
|---|---|---|
| Порядок переговоров | вопрос хозяину, затем onResponderTerminate ему и onResponderGrant претенденту | onResponderGrant претенденту, затем вопрос хозяину; при отказе претенденту ещё и onResponderReject |
| Кому адресован сдвиг | мышь — элементу под курсором | виду, где касание началось: его запоминают при касании |
| Прокрутка у предка | событие scroll: хозяина спрашивают onResponderTerminationRequest | ScrollView просит касание через onScrollShouldSetResponder, пока палец на нём, — обычные переговоры |
| Касание отменено | окно потеряло фокус — onResponderTerminate без вопроса | touchcancel — onResponderTerminate без вопроса |
onShouldBlockNativeResponder | ответ никто не читает | по комментарию в PanResponder.js — только Android: не дать нативным видам стать ответчиком |
| После жеста | onClickCapture гасит click ещё 250 мс | кликов нет |
| Пальцев | один указатель мыши | сколько положат: numberActiveTouches |
Отмена без вопроса — единственное, от чего не спасает отказ в onPanResponderTerminationRequest. На iOS так бывает, когда касание перехватывает нативный жест вне корневого вида React Native: RCTSurfaceTouchHandler уступает таким жестам и отменяет свои касания. Поэтому у свайпа есть onPanResponderTerminate: строка, у которой отняли палец, возвращается на место, а не остаётся полуоткрытой.
Для примеров отсюда следует правило: жест в предпросмотре проверяется честно только одним указателем и внутри ветки хозяина. Прокрутку настоящим пальцем, отмену системой и несколько пальцев проверяют на телефоне.
На телефоне иначе: сдвиг пальца всегда приходит виду, где касание началось; претендент получает onResponderGrant раньше, чем спросят хозяина; а касание, отменённое системой, кончается onResponderTerminate без вопроса — отказ в onResponderTerminationRequest от этого не спасает.
Замок и строка со свайпом — в том виде, в каком они лежат в подложках. Замок — в подложке первой задачи. Строка со свайпом едет в следующую статью, где решение отпускания уедет во второй поток; ей в подложке нужны ещё палец и строка var global = globalThis.
- Касание в React Native не рассылается, а достаётся одному виду — ответчику: это замок на дерево видов. Вопросы идут двумя проходами —
…Captureот корня к цели, без приставки от цели к корню, — и первое «да» кончает обход. - Без
Captureкасание берёт самый глубокий из желающих; сCaptureпредок забирает его раньше детей. - Когда хозяин есть, спрашивают только общих предков пальца и хозяина: замок передаётся лишь вверх по дереву. Хозяин соглашается или отказывает в
onResponderTerminationRequest; согласие — этоonResponderTerminateему иonResponderGrantпретенденту, отказ —onResponderRejectпретенденту. PanResponder.create— фабрика тех же обработчиков плюсgestureStateсdx,dy,vx,vy. При захватеdxобнуляется, поэтому строка, захваченная на движении, отстаёт от пальца на порог.- Свайп — горизонтальный захват на движении,
setValueпод пальцем, отказ отдавать начатый жест, возврат при отмене и пружина после отпускания. Колбэки жеста выполняются в JS, поэтомуAnimated.eventс нативным драйвером здесь не работает, хотя предпросмотр строку двигает. - В предпросмотре сдвиг мыши адресован элементу под курсором, а на телефоне — виду, где касание началось; на телефоне же претендент получает
onResponderGrantраньше, чем спросят хозяина. Прокрутку настоящим пальцем, отмену касания системой и несколько пальцев проверяют только на телефоне.
1/4Экран, строка в нём и крестик в строке отвечают true на onStartShouldSetResponder, вопросов Capture нет. Палец коснулся крестика. Кому достанется касание?
2/4Строка должна получать касание, даже если оно началось на крестике внутри неё. Что ей задать?
3/4Хозяин касания — крестик. Указатель съехал с него на соседа его строки, а сосед отвечает true на onMoveShouldSetResponder. Что будет?
4/4Строка на PanResponder забирает жест на движении, когда Math.abs(g.dx) > 10. Захват случился, когда палец был на −15, потом палец дошёл до −40. Какой dx придёт в onPanResponderMove?
Панель, которую не спрашивают
После свайпа внизу экрана «Подхода» появляется панель «Подход удалён · Вернуть». Она должна закрываться от вертикального сдвига пальца, где бы он ни начался, — даже на крестике строки. Сейчас панель остаётся открытой. Замок из статьи лежит в подложке, его не трогайте — поправьте обработчики видов.
Касание началось на крестике, и на телефоне все его сдвиги адресованы крестику: спрашивать могут только его ветку, а пока хозяин — крестик, только его предков, экран и строку. Панель — соседка, её onMoveShouldSetResponder не спрашивают ни разу, сколько бы палец ни ехал. Забрать касание у крестика может только общий предок — экран. Туда и переезжают вопрос и реакция:
Теперь на сдвиге экран отвечает «да», крестик получает onResponderTerminate, а экран — onResponderGrant и закрывает панель. Так в приложении и делают «закрыть жестом где угодно»: вопрос задаёт вид во весь экран, а панель узнаёт о жесте через состояние. Если на вертикальный сдвиг претендует ещё и строка, спросят её раньше — вопросы без приставки идут снизу вверх, — и тогда экрану нужен onMoveShouldSetResponderCapture.
Удалённый подход вернулся
Каждая строка удаляется свайпом влево. Удалили второй подход, потом первый — и второй вернулся в список. Исправьте SwipeRow, не трогая Sets: удаление должно видеть свежий список.
PanResponder.create вызван один раз — при первой отрисовке строки, и все его колбэки навсегда видят пропсы этой отрисовки. onDelete первой отрисовки фильтрует список, каким он был тогда, — со всеми тремя подходами. Это тот же снимок, что в статье части IV «Состояние — снимок, а не переменная», только держит его не обработчик нажатия, а объект, созданный однажды. Колбэкам нужен не сам onDelete, а место, где лежит последний:
Третья строка вывода становится подходы: 85 кг × 5. Так же автомат нажатия получал свежие обработчики через configure после каждой отрисовки. Пересоздавать PanResponder на каждой отрисовке — плохой выход: gestureState живёт внутри create, и объект, созданный посреди жеста, не знает, что было до него. Если бы можно было трогать Sets, хватило бы и обновления через функцию, setSets((prev) => prev.filter(…)), но ловушка осталась бы в строке: любой её колбэк, читающий пропсы, видел бы первую отрисовку.
Строка отстаёт от пальца
Строка забирает жест на движении, после порога в 10 точек, и дальше едет за пальцем — но с отставанием: палец на −60, а строка на −45. Сделайте так, чтобы строка стояла точно под пальцем.
После захвата dx считается от точки захвата, а до него копится от касания. Значит, путь до захвата виден в последнем вопросе — его и нужно прибавлять:
Вывод: палец на -60, строка на -60. Последний вопрос — тот, на который строка ответила «да»: после захвата хозяина больше не спрашивают, и slop застывает на пути до захвата. Та же поправка нужна и в onPanResponderRelease: решение «удалить или вернуть» по голому g.dx тоже опаздывает на порог.