LeCodesdocs

UIWidget

Плавающий оверлей над текущей страницей: всегда position-fixed относительно устройства, рисуется над контентом destination. Используй для шторок (bottom sheet), диалогов, тостов-с-действиями, плавающих плееров — и это санкционированный способ положить UI поверх Scene. Создай виджет один раз на уровне модуля и переиспользуй — видимость императивна. Для стандартного диалога бери готовый UIModal ниже, а не собирай scrim + анимации + обработчики закрытия вручную; для перетаскиваемой шторки с позициями примагничивания — UIBottomSheet.

show() открывает виджет как глобальный оверлей над всеми destination: он остаётся поднятым через навигацию, пока ты не вызовешь hide(). Навигация никогда не трогает неприкреплённый виджет — модалка с overlayColor всё равно блокирует взаимодействие под собой, так что закрывай её из её собственных кнопок / onOverlayTap / onBackPressed.

Чтобы виджет стал частью страницы — HUD поверх Scene — сначала прикрепи его: attachTo(owner) кладёт виджет на слой этой страницы, так что он показывается и скрывается вместе со страницей, едет в её переходе, а экран, запушенный сверху, его накрывает.

Обзор

TypeScript
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()

Создание и видимость

TypeScript
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) выключает и то, и другое для диалогов с принудительным выбором.
TypeScript
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): this
TypeScript
const 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 (проценты не поддерживаются) — сдвигай минимум на высоту самой модалки.

TypeScript
// статичная нижняя панель — одна строка отличия от диалога:
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 — привязанные меню

Для дропдауна, контекстного меню или тултипа используй UIPopoverUIModal, чей scrim по умолчанию "transparent" (невидим, но всё равно перехватывает: тап снаружи закрывает, и ничто под ним не может скроллиться, пока он открыт, так что якорь не уедет из-под него), а позиция берётся из якоря, переданного в show():

TypeScript
UIPopover(style?, children?): UIPopover   // те же перегрузки, что у UIWidget/UIModal

popover.show(anchor?): void   // anchor: любой элемент или сырая точка { x, y } (меню по долгому нажатию)
popover.hide(): void          // переход выхода, затем размонтирование — плюс всё, что есть у UIModal
                              // (isOpen, onOpen/onClose, dismissible, transition — по умолчанию
                              // фейд 120 мс)
TypeScript
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

TypeScript
widget.style({ overlayColor: "rgba(0, 0, 0, 0.5)" })   // Color | null
widget.onOverlayTap(cb: () => void): this

overlayColor добавляет полноэкранный scrim за виджетом, блокирующий все тапы под ним — это и превращает виджет в модалку (диалог / шторку). "transparent" невидим, но всё равно перехватывает; null (по умолчанию) убирает слой целиком. Тап по scrim'у вызывает onOverlayTap — обычно () => widget.hide().

Касания и кнопка «назад»

TypeScript
widget.onTouchStart(ev => …)           // ev.track({...}) для жестов перетаскивания (перетаскивание шторки)
widget.onBackPressed(cb: () => void)   // Android «назад», пока виджет поднят — обычно hide()

Виджеты — один из трёх элементов, принимающих касания (с UIButton и UIScreen) — см. События указателя.

Анимации выхода — animateTo с commit: false

TypeScript
widget.animateTo({ overlayColor, opacity, transform, …, duration?, delay?, commit?: boolean })
widget.animateFrom({ … })              // анимировать от заданных значений к текущему стилю

animateTo обычно пишет целевые значения в стиль виджета при старте. Для анимации выхода это неверно — следующий show() стартовал бы с затухших значений. Передай commit: false, чтобы проиграть анимацию без сохранения, и hide() по завершении:

TypeScript
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.

TypeScript
// размер по контенту: 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 не повторить.)

Подводные камни

TypeScript
// ✗ пересоздание виджета на каждый экран / на каждый показ
const openSheet = () => UIWidget(...).show()  // течёт новый виджет на каждый вызов
// ✓ создай один раз на уровне модуля, show()/hide() тот же экземпляр

// ✗ ждать, что он появится при создании
const w = UIWidget(...)                       // скрыт до w.show()

Смотрите также