Размер и безопасная область
Тема уже меняется без потери выбранного курса. Теперь учтём размер окна: мини-приложение может занимать не весь экран, расширяться жестом и показывать клавиатуру поверх части интерфейса. Результат урока — естественно прокручиваемая страница с выбранной стабильной высотой и учётом безопасных отступов. Мы не превращаем короткую форму в полноэкранную игру и не обещаем универсальную компоновку без просмотра на целевых клиентах.
У WebApp есть viewportHeight и viewportStableHeight. Первое значение изменяется во время движения видимой области, второе отражает последнюю устойчивую высоту. Telegram предупреждает, что частота обновления viewportHeight не предназначена для плавного привязывания элементов к движущейся нижней границе. Это описано в таблице WebApp. Для нашего документа выбираем стабильное значение и обычный поток вместо непрерывного изменения фиксированных координат.
Стабильная высота как исходное ограничение
Создадим полный модуль viewport.js. Он устанавливает собственную CSS-переменную только при пригодном значении и возвращает снятие подписки. Переданный host берётся из resolveHost, root — существующий корневой элемент документа:
export function bindViewport(host, root = document.documentElement) {
const update = () => {
const height = host?.viewportStableHeight;
if (Number.isFinite(height) && height > 0) {
root.style.setProperty('--app-stable-height', `${height}px`);
} else {
root.style.removeProperty('--app-stable-height');
}
};
const changed = (event = {}) => { if (event.isStateStable) update(); };
update();
if (!host) return () => {};
host.onEvent('viewportChanged', changed);
return () => host.offEvent('viewportChanged', changed);
}
В screen.js добавляются import bindViewport и один вызов после получения host. Это продолжение полного первого экрана; каталог и тема не пересоздаются. В screen.css body получает min-height: var(--app-stable-height, 100dvh). Минимальная высота отличается от фиксированной: длинный документ может стать больше и прокручиваться. Не добавляем overflow: hidden, который сделал бы нижние части недоступными при росте текста или появлении дополнительных полей.
При отсутствии host собственная переменная снимается, и обычный браузер использует fallback. Нулевое значение не становится высотой всей страницы. Если Telegram временно меняет область жестом, changed ждёт устойчивого события и только затем обновляет ориентир. Это не инструмент точной синхронизации анимации. Пользовательский интерфейс не должен зависеть от каждого промежуточного пикселя, когда задача состоит в выборе курса и заполнении короткой формы.
Две безопасные области
SafeAreaInset описывает системные отступы, например вырез или навигационную область устройства. ContentSafeAreaInset относится к перекрытиям элементов Telegram. В актуальном API свойства и события появились с Bot API 8.0; документация безопасных областей задаёт отдельные значения по сторонам. Мы используем CSS-переменные SDK с нулевым fallback, не придумываем постоянные числа «под любой телефон».
Для учебного обычного экрана main дополняется отступами двух слоёв, не добавляя поверх них ещё один источник тех же системных inset из env. Это точный CSS-фрагмент для текущего main; ширина и естественный поток сохраняются:
main {
max-width: 40rem;
margin: auto;
padding:
calc(1.25rem + var(--tg-safe-area-inset-top, 0px) + var(--tg-content-safe-area-inset-top, 0px))
calc(1.25rem + var(--tg-safe-area-inset-right, 0px) + var(--tg-content-safe-area-inset-right, 0px))
calc(1.25rem + var(--tg-safe-area-inset-bottom, 0px) + var(--tg-content-safe-area-inset-bottom, 0px))
calc(1.25rem + var(--tg-safe-area-inset-left, 0px) + var(--tg-content-safe-area-inset-left, 0px));
}
Основной отступ 1.25rem относится к оформлению нашего документа. Остальные значения относятся к среде. Не копируйте сумму в каждый вложенный контейнер: иначе отступ будет учитываться повторно. Также не называем этот фрагмент доказательством правильности любого fullscreen-режима. Наш пример не вызывает requestFullscreen. При переходе к другому режиму надо отдельно проверить систему координат, реальные значения inset и расположение действий, потому что клиенты могут отличаться поведением.
Клавиатура и доступность поля
Будущее поле частоты занятий должно оставаться достижимым после появления клавиатуры. Первое решение — естественный поток и прокрутка, а не вычисление большого фиксированного top. Значение viewport не является надёжным универсальным признаком открытой клавиатуры. Оно может меняться по другой причине. Поэтому не отключайте сохранение только потому, что высота уменьшилась; действие зависит от состояния формы, а размер влияет на представление.
Если появляется нижняя панель нашей страницы, определите её отличие от MainButton хоста. Не размещайте две важные кнопки в одной перекрываемой области и не вычитайте примерную высоту клавиатуры из viewport вручную. В следующем уроке предпочтём штатную основную кнопку там, где она поддерживается, и обычную кнопку документа как запасной вариант. У каждого варианта будет свой жизненный цикл, но одно действие и общий запрет повторной отправки.
Для длинного названия курса дополнительная высота может появиться без изменения viewport. Например, «Публикация статического учебного сайта из Markdown» переносится на несколько строк. Это изменение содержимого, не событие хоста. Поэтому не назначаем каждому элементу фиксированное число пикселей высоты и оставляем абзац в потоке. Увеличение текста посетителем даёт похожий эффект. Сама переменная стабильной высоты не решает компоновку строк: она лишь описывает внешнюю область, внутри которой документ должен оставаться гибким.
Отступы по левому и правому краю особенно важны после поворота устройства. Пример учитывает их на main вместе с обычным оформительским padding. Если ширина экрана мала, сумма безопасных отступов уменьшает доступное место, но это лучше перекрытия интерактивного поля. При реальном просмотре надо оценить длинную подпись, поле выбора и текст состояния одновременно. Проверка только пустого белого контейнера не показывает, сможет ли человек пользоваться итоговой формой.
Как исследовать изменение окна
Матрица ожидаемых сценариев включает обычное открытие, расширение, поворот устройства, увеличенный текст и появление клавиатуры. Для каждого нужно записать видимый результат, выбранный курс и доступность последнего элемента. Реальные значения inset можно записать отдельно, без пользовательского контекста запуска. Они помогают объяснить перекрытие, но не заменяют просмотр: корректно выглядящее число не доказывает, что CSS применён к правильному контейнеру.
Рассмотрим ошибку: разработчик один раз берёт window.innerHeight, записывает height в пикселях и затем блокирует прокрутку. После уменьшения WebView нижний статус оказывается за пределами окна. Обновление текста про ошибку не помогает, потому что его невозможно увидеть. Исправление — вернуть естественный поток, выбрать нужный ориентир высоты и определить безопасные отступы. Это связано с текущим документом, а не с универсальным рецептом «всегда 100vh».
Другой неправильный вариант — заново подписывать changed после каждого resize. Для одной функции уже есть одна подписка; обновление значения не создаёт новый экран. Cleanup снимает callback при уничтожении представления. Если вкладка просто временно скрыта, нужно понять, продолжает ли тот же экран существовать. Снятие всех обработчиков без последующего монтажа оставит его с прежними размерами при возвращении.
Ожидаемый результат — весь текст первого экрана остаётся достижимым, выбранный курс не теряется и отступы относятся к одному внешнему контейнеру. SDK, жесты, устройства и CSS здесь не проверялись выполнением; все сценарии not_run. Следующий урок добавит действие сохранения как самостоятельный модуль, сохранив эти ограничения высоты и честно отделив готовность кнопки от готовности серверной операции.