Форма без библиотек
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье поля «Подхода» научились принимать вес и повторы: нужная клавиатура, запятая вместо точки, словарь fieldKinds. Теперь из них собирается форма подхода — та, что заполняют между подходами, часто одной рукой. В части II форму давал браузер: тег <form>, отправка по Enter, переход по Tab, required с подсказкой у поля. В React Native нет ни одного из этих пунктов. Остаются состояние — как в части IV, ref с методом focus() и пара свойств поля, отвечающих за клавишу ввода на экранной клавиатуре. Из них и соберём форму: фокус идёт по полям клавишей «Далее», ошибка встаёт под полем, подход записывается. Этаж — только React Native: своего для форм у Expo нет, а библиотека форм на двух полях спрятала бы ровно то, что здесь стоит увидеть.
Сначала опись. Всё, на чём держалась форма в статьях части II «Отправка формы и событие submit» и «Встроенная валидация и своя», на телефоне либо исчезает, либо переходит к вашему коду:
| В браузере | В React Native | Кто теперь делает |
|---|---|---|
<form> и событие submit | нет, вокруг полей обычный View | своя функция записи |
| Enter в поле отправляет форму | клавиша ввода зовёт onSubmitEditing одного поля | обработчик этого поля |
| Tab — к следующему полю | Tab на экранной клавиатуре нет | focus() следующего поля через ref |
required, pattern, validity | нет | своя функция проверки |
| подсказка и фокус на первом неверном поле | нет | Text под полем и свой focus() |
<form action={…}> и useFormStatus из части IV | нет: оба держатся на элементе формы из react-dom | та же функция записи |
Начнём со строки про клавишу ввода. Её подпись на экранной клавиатуре задаёт свойство returnKeyType: "next" — «Далее», "done" — «Готово», "send" — «Отправить». Эти три вместе с "go" и "search" есть на обеих платформах, а у iOS и Android есть ещё и свои. Рядом — более новое enterKeyHint со значениями одноимённого атрибута HTML, и если заданы оба, главнее оно: та же пара, что keyboardType и inputMode в прошлой статье. Нажатие клавиши приходит в onSubmitEditing, текст поля — в e.nativeEvent.text, как у onChange. autoFocus ставит курсор в поле, как только оно появилось: форму открыли — клавиатура уже поднята под вес.
Клавиатуры в песочнице нет, поэтому клавишу жмёт помощник pressReturn(testID) из подложки: он шлёт полю Enter, а react-native-web превращает его в onSubmitEditing. Там же — fieldKinds, parseAmount и inspectField из прошлой статьи; последний теперь читает ещё и подпись клавиши. Прогноз: у веса стоит returnKeyType="next". Что напечатается и попадёт ли фокус в повторы?
Строки «фокус: повторы» нет: returnKeyType — только надпись на клавише. В предпросмотре она уехала в атрибут enterkeyhint, по которому браузер телефона подписал бы свою клавишу, и больше ничего не сделала. Сам React Native фокус тоже не передаст. В исходнике Android-поля (ReactTextInputManager.kt) системе прямо запрещают двигать фокус к соседнему полю — «since it must only be controlled from JS», им управляют только из JS. Зато поле, на котором нажали клавишу, фокус отпустило само: последняя строка примера спрашивает про это после обработчика и получает истину.
Это поведение по умолчанию, и у него есть имя — submitBehavior. Значений три:
submitBehavior | Что делает клавиша ввода | Умолчание для |
|---|---|---|
"blurAndSubmit" | зовёт onSubmitEditing и снимает с поля фокус — клавиатура уходит вместе с ним | однострочного поля |
"submit" | только зовёт onSubmitEditing; фокус и клавиатура остаются | — |
"newline" | вставляет перенос строки, onSubmitEditing не зовёт | многострочного (multiline) |
Правило записано в TextInput.js react-native 0.86: однострочному полю без явного submitBehavior достаётся "blurAndSubmit", а заданный ему "newline" превращается туда же — переносить в одной строке нечего. Старое свойство blurOnSubmit в типах помечено устаревшим, и submitBehavior его перекрывает.
Раз фокус переводит только JS, нужен доступ к следующему полю — ref, заведённый как в части IV (статья «Ссылки на узлы: useRef и ref как пропс»). Меняется то, что в нём лежит. Не HTMLInputElement, а нативный элемент поля — тот же, что в прошлой статье нашёлся в e.target, с его focus(), blur() и measure(). TextInput добавляет к нему свои методы, и в типах react-native их три: clear(), isFocused() и setSelection(). Четвёртый, getNativeRef(), в рантайме react-native есть, но в его d.ts не объявлен, и компилятор на него скажет Свойство "getNativeRef" не существует в типе "TextInput". Ни value, ни form, ни validity у него нет: значение живёт в состоянии, а не в поле.
Предпросмотр здесь снова льстит, как с e.target.value в прошлой статье. Под ref в песочнице лежит <input> от react-native-web: value у него есть, зато из трёх методов поля — только clear() и isFocused(): setSelection() нет, как нет и getNativeRef(). Компилятор держится телефона: на ref.current?.value он скажет Свойство "value" не существует в типе "TextInput".
Тип пишется так же, как для View в первой статье части: React.ComponentRef<typeof TextInput>. Короткое useRef<TextInput> компилятор не пропустит: "TextInput" относится к значению, но здесь используется как тип. Прогноз для цепочки «вес → Далее → повторы → Готово»: какие строки придут и в каком порядке?
focus(): события фокуса синхронные, и когда обработчик «Далее» вернулся, курсор уже стоял в повторах. «Готово» увидело свежие значения, потому что каждый ввод отрисовался сразу — это правило из прошлой статьи. А «ушёл: повторы» пришло последним, после конца такта: у последнего поля submitBehavior не задан, и по умолчанию оно отпускает фокус после обработчика.У веса стоит submitBehavior="submit", и в предпросмотре он не изменил ничего: react-native-web этого свойства не читает — в его TextInput есть только старое blurOnSubmit, а неизвестные свойства он молча отбрасывает. На телефоне разница видна глазами. С умолчанием "blurAndSubmit" поле по клавише завершает ввод и прячет клавиатуру: на Android это clearFocusAndMaybeRefocus(), который кончается вызовом hideSoftKeyboard(), на iOS — endEditing. Следующей строкой ваш focus() поднимает клавиатуру снова, и на каждом «Далее» она дёргается вниз и вверх. С "submit" фокус с поля не снимается, клавиатура остаётся на месте, а фокус просто переезжает к соседу.
Отсюда правило цепочки: у всех полей, кроме последнего, — returnKeyType="next", submitBehavior="submit" и focus() следующего в onSubmitEditing. У последнего — "done" и умолчание: форма заполнена, клавиатуру пора убирать.
Спросить поле, в фокусе ли оно, — ref.current?.isFocused(); это работает и в предпросмотре, и на телефоне. В типах есть и способ спросить сразу всё приложение: TextInput.State.currentlyFocusedInput(), и компилятор его пропускает. Прогноз: что будет при вызове в предпросмотре?
Из двух способов спросить, какое поле в фокусе, react-native-web кладёт в TextInput.State только старый — currentlyFocusedField. Ноль ошибок компиляции и TypeError при запуске — тот же случай «типы шире рантайма», что в статье «Что в этой песочнице настоящее». На телефоне метод есть, но и там за ним не нужно ходить: какое поле в фокусе, форма знает сама, по своим onFocus и onBlur.
Проверки браузера — required, pattern, validity — остались в части II. Здесь проверка — обычная функция: берёт значения формы и отдаёт объект ошибок, у верного поля ключа в нём нет. Числа она разбирает через parseAmount из прошлой статьи, поэтому запятая в весе ошибкой не считается. Прогноз для четырёх черновиков подхода:
У верного черновика объект пуст — «можно записывать» проверяется как Object.keys(errors).length === 0. «5,5» parseAmount честно разобрал в 5.5, и остановила его уже проверка на целое: дробных повторов не бывает. У последнего черновика неверно всё, и ошибок две — показывать надо обе, каждую под своим полем.
Теперь — когда показывать. Если выводить ошибки на каждой отрисовке, человек увидит красное под весом раньше, чем нажмёт первую цифру. В части II эту задачу решал псевдокласс :user-invalid: он ждёт, пока с полем поработают. CSS здесь нет, поэтому «поработал» запоминаем сами — в touched, когда поле теряет фокус; в цепочке это как раз нажатие «Далее». Сама ошибка — обычный Text под полем, а рамка краснеет вторым стилем в массиве, как в разделе «Массив в пропсе style: кто кого перекрывает» статьи про StyleSheet.create.
Поле с ошибкой под ним собирает компонент FormField. Ref в React 19 — обычный проп, это тоже из статьи части IV про ref, поэтому FormField просто пропускает его в TextInput вместе с остальными свойствами. validateSet уже в подложке. Прогноз: что будет написано под полями после каждой отрисовки?
Пока человек печатал в весе «8,2,5», поле молчало: оно ещё не тронуто. Красное появилось по «Далее» — фокус ушёл в повторы, вес получил onBlur. А исчезло оно сразу, как только вес исправили, не дожидаясь нового ухода с поля: touched у веса уже true, а ошибки пересчитываются на каждой отрисовке. Под повторами пусто, хотя повторов нет: туда только вошли и ещё не выходили.
Отправки нет — есть функция, которую зовёт клавиша «Готово» на последнем поле, а в полной форме ещё и кнопка. После записи поле повторов чистится, а клавиатура прячется вызовом Keyboard.dismiss(): начинается отдых, и половина экрана под клавиатурой больше не нужна. Keyboard — модуль React Native, и dismiss() у него делает одно: снимает фокус с поля, которое сейчас в фокусе. В react-native это одна строка, blurTextInput(currentlyFocusedInput()), и в react-native-web — та же, только со старым currentlyFocusedField(). Возьмём одно поле и правило показа из прошлого раздела. Прогноз: сколько отрисовок и что будет под полем после записи?
Подход записался, поле очистилось — и тут же покраснело. Причина в строке, где про ошибки нет ни слова: Keyboard.dismiss() снял фокус с поля, а снятие фокуса — это onBlur, и он пометил поле тронутым. Все три сеттера сработали внутри одного обработчика и ушли в одну очередь, как в статье части IV «Функция-обновитель и очередь обновлений»; setTouched(true) из onBlur встал в неё последним и перебил setTouched(false).
Убрать Keyboard.dismiss() не поможет. Последнее поле отпускает фокус по «Готово» и само — это умолчание "blurAndSubmit" из первого раздела. Изменится только время: в предпросмотре этот onBlur придёт тактом позже, отдельной отрисовкой, и красное появится на ней. Бороться с порядком событий бесполезно: на телефоне onBlur приходит из нативного поля событием, и когда именно — решает не ваш код. Менять надо правило показа.
Новое правило такое: пустое поле при уходе с него не ругаем — ругаем только после попытки записать. Непустое неверное поле краснеет по-прежнему, после ухода. Человек, пролиставший поле «Далее», ещё ничего не ввёл и ни в чём не ошибся, а после записи поле пустое потому, что его очистили мы. Попытку записать отмечает отдельный флаг submitted — он и показывает все ошибки разом.
Keyboard.dismiss() не «прячет клавиатуру» сам по себе: клавиатура на телефоне живёт, пока в фокусе поле. Поэтому любой вызов dismiss — это чей-то onBlur.
Соберём всё. submit зовут двое: кнопка «Записать» и «Готово» на последнем поле. При ошибке он отмечает попытку и ставит курсор в первое неверное поле — так делал браузер в части II, когда форму с ошибкой пытались отправить. Какое поле первое, решает список order — порядок полей на экране. Из него же выводится цепочка: у каждого поля, кроме последнего, «Далее» ведёт к следующему, у последнего «Готово» зовёт submit.
Вес пришёл из прошлого подхода. FormField и validateSet — в подложке. Прогноз: сначала «Записать» при пустых повторах, потом ввод повторов и «Готово». Что напечатается и где окажется фокус?
Первое нажатие ничего не записало: ошибка показалась под повторами, и курсор встал туда же — искать, что не так, не нужно. Второе прошло через «Готово»: подход записан числами, вес остался, повторы очищены, и под ними пусто, хотя Keyboard.dismiss() снова снял фокус и onBlur снова пометил поле. Пометка есть, а красного нет: поле пустое, и попытки записать ещё не было.
Третье поле — например, отдых в секундах — теперь добавляется в Field, order и validateSet, и всё. returnKeyType, submitBehavior и то, куда ведёт клавиша, пересчитаются сами: «Далее» у повторов поведёт в отдых, «Готово» переедет на отдых.
Одну ловушку формы предпросмотр не покажет вовсе. На экране тренировки форма стоит под списком подходов, а экран прокручивается — это ScrollView. Пока открыта клавиатура, первое касание мимо поля ScrollView по умолчанию забирает себе: клавиатура прячется, а кнопка «Записать» нажатия не получает, и человек жмёт второй раз. Так и написано в описании свойства keyboardShouldPersistTaps: при "never", которое стоит по умолчанию, дочерние элементы такого касания не получат. Лечится одним свойством:
С "handled" касание, которое кто-то внутри обработал, — наша кнопка — до кнопки и доходит, а клавиатуру прячет Keyboard.dismiss() в submit. Касание по пустому месту клавиатуру по-прежнему убирает. У FlatList то же свойство — он построен на ScrollView. В предпросмотре разницы не будет: в ScrollView react-native-web перехват первого касания закомментирован, а fireEvent.press — щелчок, до которого прокрутке дела нет. Что делать, когда клавиатура закрывает саму форму, — в последней статье главы.
FormField и validateSet переезжают в подложку следующих статей главы, а форма там понадобится уже готовой. Следующая разбирает, что на самом деле происходит внутри кнопки «Записать» между касанием и onPress.
- Формы в React Native нет: ни
<form>, ниsubmit, ни Tab, ниrequired. Её собирают из состояния, ref сfocus()и свойств клавиши ввода. returnKeyType(или более новоеenterKeyHint) — только подпись клавиши. Нажатие приходит вonSubmitEditing, а в следующее поле фокус переводит ваш код:nextRef.current?.focus().- Однострочное поле по умолчанию отпускает фокус по клавише ввода (
"blurAndSubmit"). В цепочке ставьтеsubmitBehavior="submit", иначе клавиатура на телефоне дёргается на каждом «Далее»; предпросмотр этого свойства не читает. - Под ref у
TextInput— нативный элемент сfocus(),blur(),clear()иisFocused(), тип —React.ComponentRef<typeof TextInput>. Значения в нём нет: оно в состоянии, поэтому и чистят состояние, а не поле. - Ошибка —
Textпод полем. Показывать её после ухода с поля или попытки записать, но пустое поле при уходе не ругать: «Готово» иKeyboard.dismiss()снимают фокус и после записи. - Одна функция
submitна кнопку и «Готово»; при ошибке — фокус в первое неверное поле по порядку на экране. Форме вScrollViewнуженkeyboardShouldPersistTaps="handled", иначе первое касание кнопки на телефоне только спрячет клавиатуру.
1/4У поля веса стоит returnKeyType="next", других свойств клавиши нет. Что будет, когда человек нажмёт «Далее»?
2/4Зачем полям цепочки, кроме последнего, submitBehavior="submit"?
3/4После записи поле повторов очищают и зовут Keyboard.dismiss(). Почему под пустым полем сразу появляется ошибка?
4/4Форма стоит внутри ScrollView. На телефоне первое нажатие на «Записать» при открытой клавиатуре ничего не записывает. Почему?
«Далее», которое никуда не ведёт
Подписи клавиш уже стоят, а цепочки нет: «Далее» на весе фокус никуда не переводит, «Готово» на повторах ничего не делает. Доведите форму: «Далее» ведёт в повторы, «Готово» печатает записать: вес × повторы. Клавиатура на телефоне между полями прятаться не должна.
Нужны ref на повторы и два обработчика клавиши; submitBehavior="submit" на весе — ради телефона: без него клавиатура закроется и откроется заново.
Вывод: фокус: вес, фокус: повторы, записать: 80 × 8. «Готово» видит «8», хотя ввод и нажатие шли в одном такте: каждый fireEvent.changeText отрисовывается сразу, и к нажатию у поля уже свежий обработчик. У последнего поля submitBehavior не нужен: умолчание снимет фокус, и клавиатура уйдёт — форма заполнена.
Крестик, который стирает не то
Рядом с весом — крестик «очистить». Он зовёт clear() — метод, который у поля правда есть, — и поле на экране пустеет. Но «Записать» после этого пишет старый вес. Почините крестик: после нажатия пусто должно быть и в поле, и в состоянии, а курсор должен стоять в весе, чтобы сразу набрать новый.
clear() — команда нативному полю, а не вашему состоянию: в исходнике react-native это setTextAndSelection с пустой строкой, в react-native-web — присваивание value узлу. Текст на экране и значение в состоянии разъехались, и подход запишется с весом, которого человек уже не видит. Чистят состояние — поле опустеет само, потому что его value берётся оттуда.
Вывод: фокус в поле, в поле: "", в состоянии: "". clear() остаётся для неуправляемых полей — тех, что живут на defaultValue и в состояние ничего не пишут.
Подтягивания: вес не обязателен
В справочнике «Подхода» есть упражнения с собственным весом: подтягивания, отжимания, планка. Там пустой вес — не ошибка, а «без отягощения». Научите validateSet понимать флаг bodyweight: пустой вес при нём проходит, а вес, который не разбирается в число, остаётся ошибкой. Для обычных упражнений ничего не меняется.
Пустоту проверяем до разбора: parseAmount вернёт null и для пустой строки, и для «1,,5», а различать нужно именно их.
Вывод: {}, {}, ошибка веса для «1,,5» и ошибка веса у жима без веса. Записывать пустой вес подтягиваний стоит числом 0, а не пустой строкой: тоннаж, графики и сравнение с прошлой тренировкой считают числа.