Привет, Хабр! На связи команда фронтенд-разработки из ecom.tech. Меня зовут Миша, я занимаюсь разработкой интерфейсов для внутренних сервисов. Например, мы сделали удобное приложение для курьеров-партнёров Самоката, сервис для быстрой работы логистов, интуитивно понятный терминал для складов.
В этой статье я расскажу, как мы переизобрели взаимодействия с инпутами и написали свою клавиатуру для мобильного приложения – вас ждёт код и пошаговое описание. Поехали!
Внутренние сервисы
Мы хотели разработать приложение, которым будут пользоваться через ТСД на складах, чтобы ускорить время приемки и отправки грузов.
Приложение нужно было сделать удобным для конечного пользователя – кладовщика. Что мы знаем о нём?
-
Работает в грубых перчатках, с небольшим ТСД – а значит, клавиатура должна иметь большие кнопки.
-
Хочет иметь быстрый доступ ко всем кнопкам на одном экране без дополнительных модалок – а значит, клавиатура должна быть простой и интуитивно понятной.
-
Не самый большой поклонник пользовательских сценариев, где нужно свайпать 🙂
Пишем свою клавиатуру
Нативная клавиатура сразу не подходит нам по понятным причинам — она слишком универсальна, там лишние символы, пользователь вынужден делать ненужные клики. Поэтому мы напишем свою виртуальную клавиатуру.
Далее я приведу несколько требований, применяемых для такого компонента (в вашем случае требования могут отличаться):
-
Управление отображением клавиатуры (иногда она не нужна).
-
Управление состоянием клавиш. Нам нужны только цифры и функциональные кнопки.
-
Некоторые визуальные улучшения. Эффект нажатия на кнопку, достаточная ширина кнопок, управление цветом, иконки.
Ниже — пример шаблона, с которым мы начали:
<template> <div v-show="isShowVirtualKeyboard" class="keyboard" :class="{ collapsed: isCollapsed }"> <button @click="onKeyPress('1')" class="key">1</button> <!-- цифры от 1 до 5 ... --> <button @click="onKeyPress('5')" class="key">0</button> <div class="divider" /> <button @click="onKeyPress('back')" class="key big-key">⌫</button> <div class="divider" /> <!-- Функциональная кнопка --> <button :disabled="!buttons['f1'].active" :style="`background: ${buttons['f1'].color}`" @click="buttons['f1'].onClick" class="key" data-test="f1-button" > {{ buttons['f1'].title }} </button> <!-- Далее продолжить второй ряд для цифр от 6 до 0 и т.д. --> </div> </template>
Каждое нажатие «стандартной» (не функциональной) кнопки виртуальной клавиатуры запускает dispatchEvent
со всплытием и значением кнопки. Клик по функциональной кнопке обрабатывается передаваемым методом из store
. Интерфейс функциональных кнопок выглядит так:
interface ButtonOptions { title?: string | ComputedRef<string> color?: string | ComputedRef<string> active?: boolean | ComputedRef<boolean> onClick?: () => void }
А вот и то, что возвращает сам стор:
const virtualKeyboardStore = () => { /** * ... * Скучный код стора * ... */ return { buttons, // функциональные кнопки setButton, // управление функциональной кнопки resetButton, // сбрасывает состояние (кнопка серая и неактивная) setActiveButton, // убирает у кнопки disabled setInactiveButton // устанавливает кнопку в состояние disabled } }
Незамысловатые стили решают задачу визуальных улучшений, а управление отображением клавиатуры устанавливается также в роутере:
meta: { isShowVirtualKeyboard: true }
Проектируем кастомный input
Виртуальная клавиатура готова.
Она будет взаимодействовать с инпутами, куда попадают все введенные данные. На мобильных устройствах инпут ведет себя привычно – ловит фокус, отображает его, вызывает нативную клавиатуру, слушает события. Требования, с учетом существования виртуальной клавиатуры, кажется, простые:
-
При фокусе не показывать нативную клавиатуру;
-
слушать события виртуальной клавиатуры;
-
поддерживать маски;
-
поддерживать валидации;
-
вводить дефолтные значения.
<template> <label @click="setActive()" class="base-label" :class="{ 'base-label_invalid': hasError }"> <span class="base-label__placeholder">{{ placeholder }}</span> <input data-test="base-input" v-model="model" type="text" ref="input" @focus="onFocus" @paste="onPaste" @blur="onBlur" @keypress="$emit('keypress', $event)" class="base-input" :name="name" :disabled="disabled" autocomplete="off" /> </label> </template>
Первым делом слушаем события из виртуальной клавиатуры и редактируем модель.
const onCustomInput = (e: { detail: string }) => { if (e.detail === 'back') { model.value = model.value.slice(0, -1) } if (!isNaN(e.detail)) { model.value += e.detail } }
Несложно, да? Но чтобы намазать варенье на хлеб, нужно сначала открыть банку. Чтобы модель начала заполняться, нужно сначала кликнуть/тапнуть на элемент… После чего мы увидим нативную мобильную клавиатуру.
Используем хак onFocus:
const onFocus = () => { // Введем поддержку virtual keyboard через дополнительный prop if (props.isVirtualKeyboard) { input.value?.setAttribute('inputmode', 'none') // хак для скрывания нативной клавиатуры input.value?.focus() } // Будем использовать дополнительный метод для "активации" элемента, // на котором фокусируется пользователь setActive() }
Чтобы событие не прослушивалось во всех инпутах сразу, введем новое состояние активности текущего инпута. setActive()
– управляет активностью элемента ввода. Пока isActive === true
, событие из виртуальной клавиатуры слушаем, а также держим нативный focus на элементе.
const setActive = async (isActive: boolean = true) => { if (!isActive) { input.value?.blur() active.value = false } else { active.value = true input.value?.focus() } } watch(active, (isActive) => { if (isActive) { document.addEventListener('customInput', onCustomInput) } else { document.removeEventListener('customInput', onCustomInput) } }) onUnmounted(() => { document.removeEventListener('customInput', onCustomInput) }) // Вдруг мы захотим управлять этим извне (спойлер: захотим) defineExpose({ setActive })
При смене фокуса нужно внимательно следить за активным элементом:
const onBlur = () => { setTimeout(() => { if (document.activeElement?.getAttribute('name') !== props.name) { setActive(false) } }, 100) }
setTimeout
встретится еще много раз. Здесь он позволяет оставлять поле активным в тот момент, когда когда пользовательский фокус (:focus
) сместится на кнопку виртуальной клавиатуры. Привязка к атрибуту решает проблему фокусирования на разных элементах, например в форме с несколькими полями ввода. Если юзер меняет фокус на другой инпут или тапает вне элемента, то должен сработать blur
и элемент станет неактивным.
Теперь маска. Просто так повесить маску на компонент нельзя – проще управлять маской через код. После каждого изменения модели (предполагаем, что это происходит, например, при нажатии на виртуальную клавиатуру), нужно устанавливать фокус назад, а также выбрасывать событие наверх.
const masked = IMask.createMask(props.mask ? { ...props.mask } : { mask: /\W*/ }) // а теперь обновленный onCustomInput const onCustomInput = (e: { detail: string }) => { if (active.value) { if (e.detail === 'back') { masked.resolve(model.value.slice(0, -1)) model.value = masked.value } // принимает новые значения только если маска не завершена if (!isNaN(+e.detail)) { masked.resolve(model.value + e.detail) model.value = masked.value } input.value?.focus() } } watch(model, (newVal) => { masked.resolve(newVal) model.value = masked.value input.value?.focus() // чтобы не слетал фокус на устройстве ТСД emit('change', model.value) })
Также здесь присутствует обработка onPaste. Резонный вопрос: зачем она нужна, если вводом в инпут управляет пользователь с виртуальной клавиатуры? Дело в том, что разные модели ТСД могут иметь разные настройки управлением ввода отсканированного значения. Какие-то эмулируют нажатие клавиш с задержкой в N мс. Какие-то эмулируют вставку значения.
После каждого сканирования ТСД отправляет события нажатия на Enter. Добавим сюда щепотку человеческого фактора – кладовщик отсканировал код дважды, трижды, оставляя фокус на одном и том же поле ввода. Помним, что добавлять «крестик» в инпут мы не хотим по причине слишком маленького элемента интерфейса, поэтому просто заменим значение.
const onPaste = (e: any) => { const pastedData = e.clipboardData.getData('Text') if (pastedData) { model.value = '' // очищает поле ввода при вставке значения из буфера setTimeout(() => { model.value = pastedData // устанавливает нужное значение в следующем тике }, 0) input.value?.focus() } }
Важно рядом с BaseInput слушать событие Enter. Например, был случай, когда ввод qr-кода в 150+ символов эмулировал нажатие клавиш с задержкой в 4 мс (150 * 4 = 600 мс) и был отсканирован дважды, что создавало лаг в 1.2 секунды и получение ошибки от сервера о невалидном введенном токене.
<!-- QRScan.vue --> <template> <div v-if="!loading" class="qr-scan"> <BaseInput ref="qr" name="qr" v-model="qrCode" placeholder="Сканируйте код реестра" @keypress="onChange" is-active is-virtual-keyboard /> </div> <div class="qr-loading" v-else>Идет загрузка...</div> </template>
// QRScan.vue - script <script setup lang="ts"> // ... const onChange = async (e: any) => { if (e.key === 'Enter') { // ... логика отправки QR кода ... } } // ... </script>
Полный код компонента BaseInput.vue
<script setup lang="ts"> import { IMask } from 'vue-imask' import type { Ref } from 'vue' import { ref, watch, onUnmounted, onMounted } from 'vue' interface IBaseInputProps { name: string mask?: any isVirtualKeyboard?: boolean placeholder?: string hasError?: boolean isActive?: boolean disabled?: boolean } const model = defineModel() as Ref<string> const props = withDefaults(defineProps<IBaseInputProps>(), { hasError: false }) const emit = defineEmits(['change', 'keypress']) const masked = IMask.createMask(props.mask ? { ...props.mask } : { mask: /\W*/ }) const input = ref<HTMLInputElement | null>() const active = ref<boolean>(false) const setActive = async (isActive: boolean = true) => { if (!isActive) { input.value?.blur() active.value = false } else { active.value = true input.value?.focus() } } const onPaste = (e: any) => { const pastedData = e.clipboardData.getData('Text') if (pastedData) { model.value = '' // очищает поле ввода при вставке значения из буфера setTimeout(() => { model.value = pastedData // устанавливает нужное значение в следующем тике }, 0) input.value?.focus() } } const onCustomInput = (e: { detail: string }) => { if (active.value) { if (e.detail === 'back') { masked.resolve(model.value.slice(0, -1)) model.value = masked.value } // принимает новые значения только если маска не завершена if (!isNaN(+e.detail)) { masked.resolve(model.value + e.detail) model.value = masked.value } input.value?.focus() } } const onFocus = () => { if (props.isVirtualKeyboard) { input.value?.setAttribute('inputmode', 'none') // хак для скрывания нативной клавиатуры input.value?.focus() } setActive() } const onBlur = () => { setTimeout(() => { if (document.activeElement?.getAttribute('name') !== props.name) { setActive(false) } }, 100) } watch(model, (newVal) => { masked.resolve(newVal) model.value = masked.value input.value?.focus() // чтобы не слетал фокус на ТСД emit('change', model.value) }) watch(active, (isActive) => { if (isActive) { document.addEventListener('customInput', onCustomInput) } else { document.removeEventListener('customInput', onCustomInput) } }) watch( () => props.isActive, (isActive) => { if (isActive) { setActive() } else { setActive(false) } } ) onMounted(async () => { if (props.isActive) { // после tick устанавливает фокус, нужно из-за задержки на onBlur() // nextTick не подойдет setTimeout(() => { onFocus() }, 0) } }) onUnmounted(() => { document.removeEventListener('customInput', onCustomInput) }) defineExpose({ setActive }) </script>
В чём польза собственной клавиатуры?
Интуитивно понятный интерфейс, не требующий дополнительных навыков или усилий от пользователей, может привести к значительному улучшению производительности и эффективности.
В нашем примере эффект от внедрения — UX-аналитика показала увеличение скорость приёмки и обработки грузов в 3.5 раза. Такая оптимизация особенно важна на большом масштабе, когда счёт идёт на сотни складов, тысячи кладовщиков, миллионы товаров.
Кроме того, виртуальные клавиатуры могут помочь уменьшить ошибки при вводе данных, что также является важной задачей для обеспечения точности и надежности операций.
Разработка виртуальных клавиатур и компонентов ввода – это шаг вперед к созданию более современных и эффективных складских операций. В каком бы домене вы ни работали, если у вас есть возможность оптимизировать ручной труд через создание более удобных интерфейсов – не сдерживайте себя!
ссылка на оригинал статьи https://habr.com/ru/articles/869930/
Добавить комментарий