UIWidget
Плавающий оверлей над текущей страницей: всегда position-fixed относительно устройства, рисуется
над контентом destination. Используй для шторок (bottom sheet), диалогов, тостов-с-действиями,
плавающих плееров — и это санкционированный способ положить UI поверх Scene. Создай виджет
один раз на уровне модуля и переиспользуй — видимость императивна. Для стандартного диалога
бери готовый UIModal ниже, а не собирай scrim + анимации + обработчики закрытия вручную; для
перетаскиваемой шторки с позициями примагничивания — UIBottomSheet.
show() открывает виджет как глобальный оверлей над всеми destination: он остаётся поднятым
через навигацию, пока ты не вызовешь hide(). Навигация никогда не трогает неприкреплённый
виджет — модалка с overlayColor всё равно блокирует взаимодействие под собой, так что закрывай
её из её собственных кнопок / onOverlayTap / onBackPressed.
Чтобы виджет стал частью страницы — HUD поверх Scene — сначала прикрепи его:
attachTo(owner) кладёт виджет на слой этой страницы, так что он показывается и скрывается
вместе со страницей, едет в её переходе, а экран, запушенный сверху, его накрывает.
Обзор
const dialog = UIWidget(
UIText("Delete item?").style({ fontWeight: 700, fontSize: 18, color: "black" }),
UIButton(UIText("Delete").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 8, height: 40, justifyContent: "center" })
.onClick(() => { doDelete(); dialog.hide() }),
)
.style({ left: 24, right: 24, top: "40%", p: 16, gap: 12, borderRadius: 16,
bgColor: "white", overlayColor: "rgba(0,0,0,0.5)" })
.onOverlayTap(() => dialog.hide())
.onBackPressed(() => dialog.hide())
// где угодно, на любом экране:
dialog.show()Создание и видимость
UIWidget(...children: (UINodeChild | UINodeChild[])[]): UIWidget
widget.show(): void // смонтировать как глобальный оверлей (до этого скрыт)
widget.hide(): void // размонтировать
widget.isShow: boolean // геттер — показан ли сейчас?
widget.attachTo(owner: Presentable | null): this // положить виджет на слой страницы владельцаПолная поверхность контейнера: .append / .insert / .remove / .setContent, .style,
.onLayout. Позиционируй его через top / left / right / bottom / width / height —
координаты в пространстве экрана устройства (значения safe-area вроде "safe-bottom" работают).
attachTo(owner) делает виджет принадлежащим этому destination (Scene, UIScreen, …): он
виден, только пока владелец презентован — скрывается, когда запушенный экран накрывает владельца,
и возвращается, когда pop его открывает — и анимируется вместе с владельцем во время переходов.
Прикрепляй до show() (пока виджет скрыт); привязка липкая до attachTo(null), который снова
делает виджет глобальным оверлеем. HUD для ARScene можно прикрепить до open(), чтобы он был
на месте с первого кадра.
UIModal — диалоги со встроенным бойлерплейтом
Для стандартного диалога используй UIModal, а не собирай паттерн вручную. Это и есть
UIWidget (та же стилизация, дети, касания и поверхность attachTo), который дополнительно:
- имеет scrim по умолчанию (
overlayColor: "rgba(0, 0, 0, 0.5)"— переопредели его или передайoverlayColor: null, чтобы убрать), - анимируется на
show()/hide()— фейд 200 мс, если не заменишь позу черезtransition()(выход играется сcommit: falseи размонтирует по завершении, так что стиль нетронут к следующему показу), - закрывается сам по тапу на scrim и по кнопке «назад» Android —
dismissible(false)выключает и то, и другое для диалогов с принудительным выбором.
UIModal(style?, children?): UIModal // те же перегрузки, что у UIWidget
modal.show(): void // смонтировать + входной переход (no-op, пока открыт)
modal.hide(): void // переход выхода, затем размонтирование (no-op, пока закрывается)
modal.isOpen: boolean // true от show() до начала hide()
modal.transition(hidden): this // заменить анимацию показа/скрытия — см. ниже
modal.onOpen(cb: () => void): this // после того как show() его смонтирует
modal.onClose(cb: () => void): this // когда начинается закрытие — тап по scrim, «назад» или hide()
modal.dismissible(enabled: boolean): thisconst confirm = UIModal(
UIText("Delete item?").style({ fontWeight: 700, fontSize: 18, color: "black" }),
UIButton(UIText("Delete").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 8, height: 40 })
.onClick(() => { doDelete(); confirm.hide() }),
).style({ left: 24, right: 24, top: "40%", p: 16, gap: 12, borderRadius: 16, bgColor: "white" })
confirm.show() // где угодно — scrim, фейд-ин, кнопка «назад» и тап по scrim уже подключеныtransition(hidden) — замена анимации показа/скрытия
hidden — это поза «за экраном»: show() анимирует из неё, hide() — в неё, а её
duration (мс, по умолчанию 200) задаёт время обоих направлений плюс отложенного размонтирования.
Поза заменяет дефолтный { opacity: 0 } целиком — слайд без фейда это просто слайд. Scrim
сохраняет собственный фейд, если поза сама не управляет overlayColor. Длины в transform — px
(проценты не поддерживаются) — сдвигай минимум на высоту самой модалки.
// статичная нижняя панель — одна строка отличия от диалога:
const panel = UIModal(…)
.style({ left: 0, right: 0, bottom: 0, height: 400, borderRadius: 20, bgColor: "white" })
.transition({ transform: "translateY(480px)", duration: 250 })
panel.show()Перетаскиваемую шторку (палец примагничивается между позициями) не собирай руками — используй
UIBottomSheet ниже.
UIPopover — привязанные меню
Для дропдауна, контекстного меню или тултипа используй UIPopover — UIModal, чей scrim по
умолчанию "transparent" (невидим, но всё равно перехватывает: тап снаружи закрывает, и ничто
под ним не может скроллиться, пока он открыт, так что якорь не уедет из-под него), а позиция
берётся из якоря, переданного в show():
UIPopover(style?, children?): UIPopover // те же перегрузки, что у UIWidget/UIModal
popover.show(anchor?): void // anchor: любой элемент или сырая точка { x, y } (меню по долгому нажатию)
popover.hide(): void // переход выхода, затем размонтирование — плюс всё, что есть у UIModal
// (isOpen, onOpen/onClose, dismissible, transition — по умолчанию
// фейд 120 мс)const item = (label: string, action: () => void) =>
UIButton(UIText(label).style({ color: "white" }))
.style({ height: 40, px: 12, justifyContent: "flex-start" })
.onClick(action)
const menu = UIPopover(
item("Rename", () => { menu.hide(); rename() }),
item("Delete", () => { menu.hide(); remove() }),
).style({ width: 220, borderRadius: 12, bgColor: "#222", py: 4 })
moreButton.onClick(() => menu.show(moreButton)) // привязано к кнопке
rowButton.onLongPress(ev => menu.show({ x: ev.clientX, y: ev.clientY })) // у пальцаРазмещение автоматическое и вычисляется один раз на показ: под левым краем якоря (зазор
4 px), с переворотом наверх, когда нет места, с прижатием на 8 px внутрь вьюпорта (флип/прижатие
уточняются на первой раскладке поповера, внутри входного фейда — коррекцию ты не увидишь).
Поповер сам прикрепляется к Presentable.current, так что скрывается вместе со страницей, на
которой открылся, и едет в её переходе; явный attachTo(owner) до show() уважается.
Привязанные поповеры не следуют за якорем — позиция-один-раз это платформенная конвенция.
Под капотом это ровно рецепт getBoundingClientRect() + attachTo (см.
overview.md) —
собирай вручную, только когда нужна логика размещения, которую компонент не предлагает.
Модальное поведение — overlayColor
widget.style({ overlayColor: "rgba(0, 0, 0, 0.5)" }) // Color | null
widget.onOverlayTap(cb: () => void): thisoverlayColor добавляет полноэкранный scrim за виджетом, блокирующий все тапы под ним — это и
превращает виджет в модалку (диалог / шторку). "transparent" невидим, но всё равно
перехватывает; null (по умолчанию) убирает слой целиком. Тап по scrim'у вызывает onOverlayTap —
обычно () => widget.hide().
Касания и кнопка «назад»
widget.onTouchStart(ev => …) // ev.track({...}) для жестов перетаскивания (перетаскивание шторки)
widget.onBackPressed(cb: () => void) // Android «назад», пока виджет поднят — обычно hide()Виджеты — один из трёх элементов, принимающих касания (с UIButton и UIScreen) — см.
События указателя.
Анимации выхода — animateTo с commit: false
widget.animateTo({ overlayColor, opacity, transform, …, duration?, delay?, commit?: boolean })
widget.animateFrom({ … }) // анимировать от заданных значений к текущему стилюanimateTo обычно пишет целевые значения в стиль виджета при старте. Для анимации выхода это
неверно — следующий show() стартовал бы с затухших значений. Передай commit: false, чтобы
проиграть анимацию без сохранения, и hide() по завершении:
const close = () => {
widget.animateTo({ opacity: 0, commit: false, duration: 250 })
setTimeout(() => widget.hide(), 250) // стиль всё ещё с opacity 1 для следующего show()
}UIBottomSheet — перетаскиваемая шторка с несколькими детентами
Виджет, прижатый к нижнему краю. По умолчанию размером с контент — высотой в своих детей
(с потолком в экран), одна позиция, перетаскивание вниз закрывает: форма action sheet, ноль
конфигурации. Добавь детенты для модели картографического приложения — позиции
примагничивания, между которыми пользователь перетаскивает на нативных хостах. Всё модальное
(scrim, onOpen/onClose, закрытие по тапу на scrim и кнопке «назад», dismissible(false))
работает ровно как в UIModal.
// размер по контенту: action sheet — это просто дети
const actions = UIBottomSheet(rows).style({ bgColor: "white", borderRadius: 20 })
actions.show()
// детенты: модель картографического приложения
const sheet = UIBottomSheet(
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
UIScrollable(results),
)
.style({ bgColor: "white", borderRadius: 20 })
.detents([0.3, 0.6, 1]) // доли высоты экрана, по возрастанию
.onDetentChange(i => { ... }) // каждая остановка: примагничивание пальцем или setDetent()
sheet.show() // въезжает к текущему детенту (изначально индекс 0)
sheet.setDetent(2) // программно, с анимацией
sheet.hide() // выезжает, scrim гаснет, размонтируется- Размер: без
detents()бокс шторки — её контент (maxHeight: "100%"; для длинного контента положи внутрьUIScrollable). Сdetents()бокс — это самый высокий детент (height+bottom: 0выставляются за тебя); нижние детенты показывают верхний срез. Смены детента и перетаскивание — чистая трансляция, контент никогда не перекладывается. - Перетаскивание (нативные хосты): примагничивание пальцем с учётом скорости, rubber-band
выше верхнего детента и передача скролла —
UIScrollableвнутри скроллится нормально на верхнем детенте, а тяга вниз с его верха передаёт жест шторке. На вебе шторка статична; смены детента анимируются. - Закрытие: перетаскивание ниже нижнего детента закрывает шторку (вызывает
onClose);dismissible(false)вместо этого сворачивает её к нижнему детенту — персистентная шторка в стиле карт. - Создай её один раз на уровне модуля, как каждый виджет.
(Полностью кастомный жест всё ещё возможен с обычным UIWidget + трекингом onTouchStart
claim: "pan-y" — см. touch — но сначала бери UIBottomSheet:
нативную физику перетаскивания из JS не повторить.)
Подводные камни
// ✗ пересоздание виджета на каждый экран / на каждый показ
const openSheet = () => UIWidget(...).show() // течёт новый виджет на каждый вызов
// ✓ создай один раз на уровне модуля, show()/hide() тот же экземпляр
// ✗ ждать, что он появится при создании
const w = UIWidget(...) // скрыт до w.show()Смотрите также
- UIScreen и Router — стек экранов, над которым плавают виджеты.
- События указателя и жесты —
ev.track(),claim. - Стилизация —
animate,animateTo, трансформы.