TextInput: управляемое поле и вид клавиатуры
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Подход записывают в зале: между двумя подходами, часто одной рукой, пока другая держит бутылку. Поэтому поле ввода в «Подходе» — не мелочь, а главный элемент экрана. Управляемое поле вы знаете по части IV: value из состояния, новый текст — обратно в состояние. TextInput эту схему оставляет, но меняет в ней три вещи: текст приходит строкой, а не событием с target; на телефоне он сперва попадает в нативное поле и только потом в JS; и у поля появляется свойство, которого у браузерного поля на компьютере не было вовсе, — клавиатура. Всё в этой статье живёт на нижнем этаже, в React Native: Expo к полю ввода ничего своего не добавляет.
Управляемое поле устроено как в части IV (статья «Управляемые поля: состояние вместо DOM»); меняется сам обработчик. У TextInput есть onChangeText, и получает он не событие, а готовую строку. onChange с событием тоже остался — в примере стоят оба. Прогноз: в каком порядке они сработают на один ввод и сколько раз напечатается «рендер»?
Сначала onChange, потом onChangeText, оба от одного и того же ввода, и отрисовка после них одна. Текст в событии лежит не в target, а в e.nativeEvent.text, и onChangeText — это он же, уже вынутый из события. Никакой магии тут нет, это видно по исходнику самого TextInput:
Две последние строки понадобятся в следующем разделе. А пока — привычка из части IV, от которой пальцы сами не отвыкнут: достать текст из узла, на котором случилось событие. Прогноз в два этажа: что скажет компилятор и что напечатает пример?
Компилятор отказывается: Свойство "value" не существует в типе "ReactNativeElement". Типы здесь настоящие, из пакета react-native 0.86, и по ним в e.target лежит не узел <input>, а нативный компонент: у него есть focus() и measure(), а value нет. Пример при этом отработал, и вес доехал до состояния. Это то место, где предпросмотр вам польстил: под TextInput в песочнице лежит обычный <input> от react-native-web, а у DOM-события target.value есть. На телефоне в состояние лёг бы undefined, и сборку это не остановило бы: Metro снимает типы, не проверяя их. Верьте здесь компилятору, а не предпросмотру.
onChange нужен редко — когда кроме текста важно само событие. Для всего остального есть onChangeText, и в нём нечего перепутать.
В части IV было правило: после каждого события React сверяет поле с value, поэтому обработчик, не позвавший сеттер, работает как фильтр (раздел «Поле, в котором нельзя печатать»). Проверим его на поле повторов — туда нужны только цифры. Прогноз: сколько будет отрисовок и что окажется в поле после двух вводов?
Отрисовок две — при монтировании и после «12»; на «12x» сеттер не позвали, и отрисовки не было вовсе. А поле уже на следующей строке кода снова показывает 12: react-dom вернул узел к value прямо внутри события, и «x» не пробыл на экране ни мгновения.
На телефоне тот же код ведёт себя иначе, и дело не в React, а в том, где живёт текст. Букву принимает нативное поле — наследник UITextField на iOS, EditText на Android, — оно же сразу её и рисует. В JS она приезжает событием с номером (eventCount из листинга выше), а ответ «верни как было» уезжает обратно командой setTextAndSelection с тем же номером — по нему нативная сторона понимает, на какое из её событий пришёл ответ. Между этими моментами «x» на экране есть. Исходник TextInput говорит об этом прямо: синхронизация нужна из-за асинхронной природы событий ввода — на случай, когда натив текст уже обновил, а JS решил обновление отбросить. Отсюда мигание: буква появляется и исчезает.
Лечится это не ловкостью в JS, а тем, чтобы лишнее не набиралось вовсе. Длину ограничивает maxLength: его проверяет само нативное поле, ещё до JS, и в типах react-native у него так и написано — ставьте его вместо логики в JS, чтобы не было мигания. Состав ограничивает клавиатура без букв, о ней следующий раздел. Фильтр в onChangeText остаётся последним рубежом — от вставки из буфера и внешней клавиатуры, — но не единственным.
В первой статье части было правило чтения: несколько fireEvent в одном такте — одна отрисовка. Там нажимали кнопку, а теперь в примерах появилось поле. Прогноз: сколько строк «рендер» даст пара нажатий в одном такте и сколько — пара вводов?
Нажатия склеились, как в статье «Первый экран: тот же React, другие элементы». А вводы — нет: управляемое поле react-dom обязан сверить с value сразу после события, поэтому доводит отрисовку до конца, не дожидаясь конца такта. Значит, правило первой статьи верно для нажатий, а для ввода его надо уточнить: после fireEvent.changeText поле можно читать следующей же строкой, а после fireEvent.press — не раньше, чем доработает текущий код; в примерах это следующий setTimeout.
У браузерного поля на компьютере клавиатура одна — та, что под руками. У телефона их с десяток, и какую показать, решает поле. В React Native для этого два свойства: старое keyboardType и новое inputMode с теми же значениями, что у одноимённого атрибута HTML; если заданы оба, главнее inputMode. Клавиатуры в песочнице нет, зато видно, во что react-native-web превратил свойства, — в атрибуты <input>, по которым браузер телефона выбрал бы клавиатуру. Прогноз: что получат пять полей?
Вес и повторы разошлись по inputmode: decimal и numeric. Почта ушла не в inputmode, а в type="email". В последнем поле победил inputMode, как и обещано. А numeric в предпросмотре дал то же, что number-pad. На телефоне он означает другое — вот что делают со значениями нативные исходники react-native 0.86 (RCTTextInputUtils.mm для iOS, ReactTextInputManager.kt для Android):
keyboardType | iPhone | Android | предпросмотр |
|---|---|---|---|
decimal-pad | UIKeyboardTypeDecimalPad: цифры и десятичный разделитель | TYPE_CLASS_NUMBER + FLAG_DECIMAL | inputmode="decimal" |
number-pad | UIKeyboardTypeNumberPad: только цифры | TYPE_CLASS_NUMBER | inputmode="numeric" |
numeric | UIKeyboardTypeDecimalPad — как decimal-pad | TYPE_CLASS_NUMBER + FLAG_DECIMAL + FLAG_SIGNED: ещё и минус | inputmode="numeric": разделитель не обещан |
email-address | UIKeyboardTypeEmailAddress | TYPE_CLASS_TEXT + VARIATION_EMAIL_ADDRESS | type="email" |
phone-pad | UIKeyboardTypePhonePad | TYPE_CLASS_PHONE | type="tel" |
Строка numeric — единственная, где разошлись все три платформы: на iPhone это та же клавиатура, что decimal-pad, на Android к ней добавляется минус, а в браузере разделителя может не оказаться. Поэтому в «Подходе» пишем прямо, что нужно: вес — decimal-pad, повторы — number-pad. У inputMode соответствие то же: на телефоне decimal превращается в decimal-pad, а numeric — в number-pad.
Клавиатура — подсказка, а не замок. Вставку из буфера и внешнюю клавиатуру она не остановит, поэтому фильтр из прошлого раздела остаётся.
Десятичный разделитель на decimal-pad берётся из региона телефона. На iPhone с русским регионом это запятая, и вес приходит в onChangeText как "82,5". На Android — как решит клавиатура: react-native принимает любую клавишу, которую она покажет (в ReactEditText.kt ради этого подменён обработчик клавиш), так что приехать может и точка, и запятая. В состоянии поля, как и в части IV, лежит строка; вопрос в том, как получить из неё число. Прогноз для четырёх строк, которые на самом деле приходят с таких клавиатур:
Number честно говорит NaN, а parseFloat молча отрезает всё после запятой: вес «82,5» станет 82, и полкило пропадёт из дневника без единой ошибки. Последняя строка ещё коварнее: пустое поле для Number — ноль, то есть «ещё не ввели» превращается в «гриф без блинов». Разбор пишется один раз, и берут его все поля с числами. Прогноз: что он вернёт для "8,2,5"?
replace со строкой меняет только первую запятую, поэтому «8,2,5» становится «8.2,5» и честно проваливается в null, а не превращается в 8.25. Пустое поле — тоже null: у числа для «ещё не ввели» представления нет, это правило из части IV. Недописанное «82,» даёт 82 — пока человек не допечатал дробь, это правильный ответ.
У «Подхода» есть вход — почта и пароль. Пароль прячется свойством secureTextEntry. Но у поля есть и умолчания, которых вы не задавали. Прогноз: что react-native-web отдаст браузеру для трёх полей ниже и что вы увидите на экране примера в третьем?
Первое: autocapitalize="sentences" у всех трёх, хотя никто его не просил. Это умолчание самого React Native, а не веба, и рядом с ним второе — autoCorrect, включённый по умолчанию. На телефоне почта превратится в «Rico@mail.ru» после первой же буквы, а автоисправление может заменить незнакомое слово в адресе на знакомое. Второе: пароль в многострочном поле виден целиком — посмотрите на экран примера. react-native-web для multiline рисует <textarea>, у которой скрытого режима нет, и secureTextEntry просто пропадает. На телефоне то же самое, только сказано словами: в описании свойства в react-native написано, что с multiline={true} оно не работает.
Поэтому поле почты получает autoCapitalize="none" и autoCorrect={false}, а оба поля — autoComplete: по нему менеджер паролей понимает, что предложить. Значения email, current-password (вход), new-password (регистрация) и one-time-code (код из СМС) по типам react-native работают на обеих платформах. У iOS есть и своё свойство с тем же смыслом — textContentType (password, newPassword, oneTimeCode).
onEndEditing в типах react-native есть, а в react-native-web его нет: в предпросмотре он не вызовется ни разу, хотя focus и blur приходят. Конец ввода здесь ловите через onBlur или onSubmitEditing — на телефоне работают все три.
Найденное удобно не повторять на каждом экране, а собрать в один словарь: экран берёт поле по смыслу — «вес», «повторы», «пароль», — а не вспоминает, какая клавиатура нужна весу. Тип значений — настоящий TextInputProps из react-native, поэтому опечатку ловит компилятор: keyboardType: "decimal" вместо "decimal-pad" даст Тип ""decimal"" не может быть назначен для типа "KeyboardTypeOptions | undefined", а рантайм промолчал бы и открыл обычную клавиатуру. parseAmount из прошлого раздела и inspectField лежат в подложке. Прогноз: сколько раз напечатается тоннаж и какими будут значения?
Три строки тоннажа — по одной на отрисовку: при монтировании, после веса и после повторов. После веса тоннаж ещё null: повторов нет, а ноль вместо пустоты мы запретили сами. Вес пришёл с запятой и посчитался верно. Свойства из словаря стоят в разметке первыми, до testID, value и onChangeText: так экран может переопределить любое из них, а словарь не перетрёт экранное.
Словарь fieldKinds, parseAmount и inspectField переезжают в подложку следующей статьи. Там из этих полей собирается форма подхода: фокус переходит по полям кнопкой «Далее» на клавиатуре (returnKeyType и onSubmitEditing), ошибка встаёт рядом с полем, результат записывается.
- Управляемое поле — как в части IV, но новый текст приходит в
onChangeTextготовой строкой. В событииonChangeон лежит вe.nativeEvent.text, аe.target— нативный компонент безvalue. e.target.valueв предпросмотре срабатывает, потому что под ним DOM; компилятор с настоящими типами его запрещает, на телефоне этоundefined, а Metro типы не проверяет.- На телефоне текст сперва принимает нативное поле, а JS поправляет его только после события — поэтому фильтр в
onChangeTextмигает, аmaxLengthи клавиатура без букв работают до JS. - Клавиатуру выбирают
keyboardTypeилиinputMode, и второй главнее: вес —decimal-pad, повторы —number-pad;numericна трёх платформах означает три разные клавиатуры. - С русским регионом вес приходит с запятой:
Numberдаст NaN,parseFloatмолча отрежет дробь. Разбирать своей функцией, а пустое поле считать «не ввели», а не нулём. - По умолчанию поле ставит заглавную букву и исправляет слова: почте нужны
autoCapitalize="none"иautoCorrect={false}, паролю —secureTextEntryиautoComplete, и никогда —multiline.
1/4Что получает обработчик onChangeText?
2/4Обработчик onChange={(e) => setWeight(e.target.value)} в предпросмотре работает, хотя компилятор подчеркнул value. Что будет на телефоне?
3/4Поле повторов фильтрует буквы в onChangeText. В предпросмотре всё чисто, а на телефоне буква на миг появляется и исчезает. Почему?
4/4Какую клавиатуру дать полю веса, если «Подход» выходит и на iPhone, и на Android?
Повторы: только цифры и не больше трёх
В подложке — inspectField. Поле повторов уже с number-pad и maxLength, но в предпросмотре принимает всё подряд. Добейтесь, чтобы после трёх вводов в поле оставалось 12. А потом ответьте: почему maxLength={3} не остановил «1234», хотя он в разметке есть?
Фильтр — тот же, что в статье, только с длиной: сеттер зовётся лишь тогда, когда текст подходит целиком.
Вывод становится 12, 12, 12. А maxLength в предпросмотре не помог потому, что fireEvent.changeText ставит значение программно, как скрипт, а атрибут maxlength браузер применяет только к набору с клавиатуры. Убирать maxLength всё равно не надо: на телефоне его проверяет само нативное поле (на iOS — в обработчике изменения текста, на Android — фильтром длины), до JS и без мигания. number-pad на телефоне не даст набрать букву, а фильтр в JS ловит то, что обходит обе защиты: вставку из буфера и внешнюю клавиатуру.
Глазок у пароля
Рядом с паролем — кнопка «показать», которая пока ничего не делает. Сделайте так, чтобы первое нажатие показывало пароль, а второе прятало обратно. Подпись кнопки пусть тоже меняется. Помните из статьи: после fireEvent.press поле читается не следующей строкой, а в следующем setTimeout.
Видимость — обычное состояние, а secureTextEntry — обычный пропс, который от него зависит.
Вывод: password, text, password. Набранное при переключении не теряется: текст лежит в состоянии, а меняется только способ его показать. Поле при этом остаётся однострочным — вспомните раздел про multiline: там показывать и прятать было бы нечего, пароль виден всегда.
Вес, который нельзя набрать
parseAmount из статьи пропускает то, чего человек в зале набрать не мог, но мог вставить из буфера: минус, экспоненту, шестнадцатеричную запись. Напишите parseWeight: только цифры с необязательной дробной частью через точку или запятую, от 0 до 500 кг, иначе null.
Сперва форма, потом число: Number слишком сговорчив и понимает запись, которую человек в поле веса в виду не имел.
Вывод: 82.5 и четыре null. Нижняя граница проверяется самим выражением: минуса оно не пускает. Обратите внимание, что недописанное «82,» теперь тоже null — и это нормально: в состоянии лежит строка, человек видит в поле свои «82,», а тоннажа просто нет, пока он не допечатает дробь.