Я юрист. Я не должен был знать слово adjustResize. Сейчас оно мне снится. Это история про три недели борьбы с Android-клавиатурой в WebView, про MutationObserver, который я призвал и пожалел, и про то, как настоящее решение оказалось не там, где я искал. Если у вас в приложении WebView и формы с инпутами — возможно, я сэкономлю вам неделю.

Я не должен был это знать
Меня учили читать законы и договоры. Там есть структура, иерархия норм, правовые позиции пленума, разъяснения Верховного суда. Когда я начал делать своё приложение, я думал: ну, разработка чем-то похожа. Есть документация, есть best practices, есть правильные решения.
Так не получилось.
Архитектура у меня странная: Flutter как тонкая нативная оболочка, вся UI-логика на vanilla JS внутри WebView. Никаких React, BLoC, Riverpod. Один монолитный app.js. Я знаю, что вы сейчас подумали — я тоже так подумал, когда выбирал стек. Но у меня было правило: я делаю это один. На стеке, который я знаю хуже, я бы не успел.
Всё работало, пока я не сделал первую форму с текстовым полем.
В трекере привычек форм много: создать привычку, отредактировать, добавить напарника, ввести код битвы, написать заметку. Если форма не открывается без фризов и не скрывается без артефактов — приложение можно не публиковать.
Я открыл форму. Кликнул в <input>. Поднялась клавиатура. Приложение замерло на 400 миллисекунд. Закрыл клавиатуру — снизу остался белый прямоугольник высотой 280 пикселей. Я моргнул. Прямоугольник остался.
Так начались три недели, которые я никогда не верну.
Раунд 1: adjustResize — приходит и всё переставляет
В Android есть параметр android:windowSoftInputMode, который управляет тем, что происходит, когда поднимается клавиатура. Документация говорит коротко: четыре значения, выбирайте подходящее.
Я выбрал adjustResize. Логика подсказывала: клавиатура поднимается → вьюпорт ужимается → форма помещается в оставшееся пространство. Так делают нативные Android-приложения, и это работает.
В WebView это работает не так. Точнее, работает — но плохо.
Когда adjustResize ужимает вьюпорт, WebView получает событие resize. На каждом кадре анимации появления клавиатуры. Это не один resize, это серия из 8-12 resize событий за 250 миллисекунд. Каждый resize заставляет WebView пересчитать layout всего DOM.
У меня в форме <div class="modal-content"> с backdrop-filter: blur(20px) и тенями. На каждом resize-кадре все эти эффекты пересчитываются заново. Это не дёшево. Поэтому за время появления клавиатуры — 4 пропущенных кадра, чёрные паузы, и пользователь думает, что приложение зависло.
adjustResize ведёт себя как судебный пристав. Приходит, переставляет всю мебель, уходит. Юридически — всё правильно. Практически — после визита нужно неделю ставить на место.
Я попробовал минимизировать ущерб. Убрал backdrop-filter. Стало быстрее, но не стало быстро. Убрал тени. Стало ещё быстрее. Убрал анимации. Получилось приложение, которое выглядит как макет.
Я понял: adjustResize — это не моё значение.
Между раундами: я призвал MutationObserver
В этот момент мне в голову пришла мысль, которая в обычной жизни приходит юристу: если систему нельзя обойти, можно построить параллельную.
Я начал писать собственную keyboard-машину. Идея: я сам перехвачу момент появления клавиатуры, сам зафризю модалку, чтобы она не перерисовывалась, и сам разморожу, когда клавиатура встанет.
За три дня я написал:
freezeModal()— снимает с модалкиbackdrop-filter, тени, анимации, ставит фиксированную высотуunfreezeModal()— возвращает обратно__kb_spacer— невидимый<div>высотой с клавиатуру, чтобы низ модалки не залезал под IME_kbLockUntil— таймер, защищающий от race-condition между focus и blur__imeTransition— флаг, что в данный момент идёт анимация клавиатурыDeferred render wrapper — обёртка над
render(), которая ставит обновления в очередь, если идёт анимацияensureVisible(element)— функция, которая прокручивает контейнер так, чтобы инпут не был перекрыт клавиатуройListener на
visualViewport.resize— для отлова реального состояния viewportMutationObserverнаdocument.body— чтобы ловить любые изменения DOM, которые могут произойти во время IME-анимации, и тормозить их
MutationObserver — это как поручительство по всем обязательствам должника. Ты подписываешь один листок, и теперь ты отвечаешь за каждое его движение, включая поход в магазин за хлебом.
После того как я подключил Observer, приложение стало медленным везде. Не только при клавиатуре. Каждое обновление состояния (а у меня глобальный state с debounced save в localStorage) триггерило observer. Observer проверял, не идёт ли IME-анимация. Проверка стоила миллисекунду. Один render привычки — 50-100 DOM-изменений. На каждый toggle привычки — фриз.
Я уменьшил scope Observer’а — стало лучше. Я добавил debounce — стало терпимо. Я добавил RAF-обёртку — стало почти нормально.
Через две недели у меня было:
600 строк кастомной keyboard-логики
Приложение, которое работает на 30% быстрее, чем без всей этой машинерии
Случайные фризы, которые я не могу воспроизвести
Растущее ощущение, что я делаю что-то не то
В законах есть принцип: если формальное соблюдение нормы привело к злоупотреблению, суд может применить ст. 10 ГК — отказ в защите права. Применительно к моей keyboard-машине это звучало как: я формально соблюдаю best practices, но фактически делаю приложение хуже.
Я снёс всё.
Раунд 2: adjustNothing — формально есть, фактически ничего
В AndroidManifest я сменил adjustResize на adjustNothing. В Flutter — Scaffold(resizeToAvoidBottomInset: false). Это значит: нативная сторона вообще не реагирует на появление клавиатуры. Вьюпорт не ужимается, виджеты не двигаются, никаких resize-событий в WebView не приходит.
Фризы исчезли мгновенно. Открытие формы — плавное. Печать — без задержек. Закрытие клавиатуры — мгновенное.
Появилась другая проблема: клавиатура наезжает поверх контента. Если поле ввода в нижней половине экрана, после фокуса оно оказывается под клавиатурой. Пользователь видит свою клавиатуру и не видит, что он печатает.
Это adjustNothing. Юридически — соблюдено. Фактически — клавиатура не понимает, для чего она там вообще.
Я начал думать про костыли: автопрокрутка к фокусу, искусственные отступы, кастомный скролл. Все варианты выглядели как продолжение той же ошибки, что я уже совершил с MutationObserver. Параллельная система поверх системы поверх системы.
Я остановился. И задал себе вопрос, который раньше не задавал.
Откровение: я не туда смотрел
Все три недели я воевал с windowSoftInputMode. Я выбирал между четырьмя плохими вариантами и пытался допилить выбранный. Я не задался вопросом, почему именно мои формы так чувствительны к клавиатуре.
Все мои формы были центрированными модалками. CSS-стиль display: flex; align-items: center; justify-content: center поверх fullscreen-overlay. Внутри <div class="modal-content"> с фиксированной шириной, max-height: 85vh и overflow-y: auto.
Когда клавиатура появляется (любым способом — adjustResize, visualViewport, костылями, чем угодно), у такой модалки две проблемы:
Геометрия. Модалка центрирована относительно вьюпорта. Если viewport ужался — модалка должна перецентрироваться. Это лишний reflow и моргание.
Скролл. Внутренний
overflow-y: autoпытается прокрутить контент к фокусу. Но контейнер сам в этот момент меняет размеры. Скролл-позиция «прыгает».
Любая центрированная fullscreen-модалка с инпутом — это конфликт. Не из-за adjustResize, не из-за backdrop-filter. Из-за того, что она требует, чтобы у неё было центрирование, а клавиатура отнимает у неё это право.
Решение — не центрировать. Решение — прибить форму к низу экрана.
Это называется bottom sheet. Я не изобрёл, я просто наконец увидел.
Раунд 3: bottom sheet и тишина
Bottom sheet — это панель, которая всегда стоит внизу. У неё нет центрирования. У неё нет flex-justify-center, который надо пересчитывать. У неё фиксированная нижняя граница: bottom: 0, position: fixed.
Когда клавиатура поднимается с adjustNothing, она тоже встаёт снизу. Bottom sheet и клавиатура — две панели, прибитые к одному и тому же краю. Они не конфликтуют. Они просто соседи.
Я переписал все формы с центрированных модалок на bottom sheet за два дня:
function openSheet(html, options = {}) { const sheet = document.getElementById('sheet'); const panel = sheet.querySelector('.sheet-panel'); const body = sheet.querySelector('.sheet-body'); body.innerHTML = html; if (options.title) { sheet.querySelector('[data-sheet-title]').textContent = options.title; } sheet.classList.add('active'); } function closeSheet() { document.getElementById('sheet').classList.remove('active'); }
CSS:
#sheet { position: fixed; inset: 0; z-index: 1100; visibility: hidden; } #sheet.active { visibility: visible; } .sheet-panel { position: absolute; bottom: 0; left: 0; right: 0; height: 92dvh; background: rgba(255,255,255,0.85); backdrop-filter: blur(20px); border-radius: 24px 24px 0 0; transform: translateY(100%); transition: transform 0.3s ease; } #sheet.active .sheet-panel { transform: translateY(0); }
Поверх — два правила, которые я добавил для случаев, когда у меня всё-таки остаётся старая центрированная модалка (для пары экранов, где клавиатура не нужна — например, календарь):
body.kb-open .modal-content { backdrop-filter: none; box-shadow: none; filter: none; transition: none; } body.kb-open .sheet-panel { backdrop-filter: blur(20px); /* sheet может оставить blur, потому что не перерисовывается */ }
Класс body.kb-open я навешиваю при focusin на текстовое поле и снимаю при focusout. Это глобальный флаг «сейчас идёт ввод» — он отключает glassmorphism и анимации на старых модалках, чтобы они не лагали. Sheet — оставляет, потому что он стоит внизу неподвижно и его перерисовка не дорогая.
Никаких MutationObserver. Никакого freezeModal. Никакого __kb_spacer. Никакого visualViewport.resize listener’а. Никакой кастомной keyboard-машины.
Шесть строк CSS и три строки JavaScript заменили 600 строк, которые я писал три недели.
Что я снёс из кода и что обещаю себе никогда не возвращать
В моём KEYBOARD_MODAL_NOTES.md есть раздел капслоком. Цитирую дословно:
УДАЛЕНО, не возвращать:
freezeModal/unfreezeModal,__kb_spacer,kbLockUntil,_imeTransition, deferred render wrapper,ensureVisible,visualViewportresize listener, MutationObserver капитализации.
Это как ст. 10 ГК для меня самого: если я когда-нибудь снова потянусь к MutationObserver для решения keyboard-проблемы — я знаю, что я уже один раз делал злоупотребление этим правом, и суд (то есть будущий я) откажет в защите.
Что я понял
Урок 1. adjustResize в Flutter+WebView — это плохая идея. Каждый resize-кадр триггерит layout всего DOM. Если у вас есть backdrop-filter и анимации — вы получите фризы.
Урок 2. adjustNothing сам по себе — тоже плохая идея. Клавиатура наезжает на контент, нижние поля становятся невидимыми.
Урок 3. adjustNothing в комбинации с bottom sheet — это хорошая идея. Sheet прибит к низу, клавиатура встаёт сверху него, конфликта геометрии нет.
Урок 4. Если форма с инпутом лагает — не оптимизируйте, перенесите её из центрированной модалки в bottom sheet. С 90% вероятностью вы только что сэкономили себе три недели.
Урок 5. MutationObserver — это не инструмент для решения keyboard-проблем. Это инструмент для интеграции с чужой DOM-системой, которую вы не контролируете. Если вы пишете своё приложение — вы контролируете DOM-систему. Используйте обычные event listeners на конкретных элементах.
Урок 6. Если вы юрист и пишете приложение — будьте готовы, что между «я понял симптом» и «я нашёл причину» может пройти три недели. И что причина окажется не там, куда указывала документация.
Эпилог
Я не разработчик. Я не знаю, было ли «правильно» переписывать формы с modal на sheet, или есть более элегантный способ. Возможно, разработчик с десятилетним стажем посмотрел бы на мою архитектуру и сказал: «Перепиши на нативный Compose». Возможно, он был бы прав.
Но у меня есть приложение, которое работает. Формы открываются плавно. Клавиатура не вызывает фризов. Пользователи могут печатать.
Если ваша задача стояла так же — возможно, я только что сэкономил вам три недели. Если у вас есть более правильный путь — расскажите в комментариях, я научусь.

Если хочется потрогать руками: «Склейка» — трекер привычек, в RuStore. Все формы открываются через bottom sheet, который описан в этой статье.
В следующей статье — про Android-уведомления, которые молчат на Samsung как ответчик в гражданском процессе. Подписывайтесь.