7. OverlayHost 与传送门(portals)
OverlayHost 是一个用于 tooltip、popup、modal、下拉菜单、toast 以及开发者控制台的传送门容器。 它渲染在路由屏幕之上(在 screen-space DOM 中是最后一个 sibling——UI Toolkit 没有 z-index)。
World-space UI(血条、姓名牌)——始终在屏幕之下,绝不放进这个 host。 World 标记属于 3D 对象,所以菜单或 HUD 必须能够遮住它们——它们绝不能存在于 OverlayHost (最顶层)中。有两种模式,都在屏幕之下:
- 方案 A(默认——
SusApp/SusBootstrap.EnsureWorldSpacePanel):一个独立面板 (SusWorldSpacePanel,PanelSettings.renderMode = WorldSpace),由相机绘制在所有屏幕 UI 之下。 - 方案 B(回退方案,没有 world-space 面板):
WorldSpaceService把扁平的 screen-space 标记渲染进一个专用的WorldMarkerLayer——屏幕根的第一个 child(z 值最低, 低于屏幕以及这个 OverlayHost)——每帧通过WorldToScreenPoint重新定位。
参见 API 参考中的 WorldSpaceService 以及 11-api-reference。
SusApp 总是在 UIDocument 根上构建一个固定的三层脚手架——world 标记留在屏幕之下, overlay 留在最上层,而所有应用内容都挂载到中间的 ScreenHost:
World-space panel (separate UIDocument) ← variant A, UNDER screens
└── healthbar / nameplate / floating damage
Screen-space UIDocument (root)
├── WorldMarkerLayer ← first child = lowest (variant-B world markers)
├── ScreenHost ← app content: Mount<T> component / router SusRouteView
└── OverlayHost ← last child = always on top of screens
├── transition (Transition = 10)
├── modal (Modal = 20)
├── tooltip (Tooltip = 30) ← above modals
├── dropdown (Dropdown = 40) ← above modals
├── toast (Toast = 45)
└── console (Console = 50) ← topmostTooltip 和下拉菜单被特意放在模态框之上:一个 modal 可能包含 Select、菜单或带有 hover tooltip 的控件——这些 popup 不能被绘制在对话框下面。
创建
AddToOverlay / RemoveFromOverlay 是 OverlayHost 上的方法(不在 SusComponent 上)。 通过 SusBootstrap 获取 host:
var overlay = SusBootstrap.GetOrCreateOverlay(root);
var popup = BuildMyPopup();
overlay.AddToOverlay(popup, OverlayCategory.Dropdown, dismissOnClickOutside: true);
// ...
overlay.RemoveFromOverlay(popup);
overlay.AddToOverlay(myTooltip, OverlayCategory.Tooltip);
overlay.AddToOverlay(myModal, OverlayCategory.Modal);OverlayCategory —— z 顺序
数值越大 = 越晚绘制 = 越靠上(没有 USS z-index)。数值来自 sus-core/Runtime/OverlayCategory.cs:
| 分类 | 数值 | 作用 |
|---|---|---|
World | 0 | 遗留 / 内部使用。 World 标记不应该放进这个 host(否则会盖在屏幕上面)。它们渲染在屏幕之下——方案 A 在独立的 SusWorldSpacePanel 中,方案 B 在专用的 WorldMarkerLayer 中(根的第一个 child)。这个值只是为了保留给已废弃的 WorldSpaceService.OverlayHost 回退路径。 |
Transition | 10 | 路由过渡幕布 |
Modal | 20 | 对话框、抽屉 |
Tooltip | 30 | 悬停提示(在 modal 之上) |
Dropdown | 40 | 菜单、自动补全(在 modal 之上) |
Toast | 45 | Snackbar |
Console | 50 | 开发者控制台 / 错误浮层(最顶层) |
在同一个分类内,最后添加的条目在最上面(modal 栈、toast 栈等)。
OverlayEntry
var entry = overlay.AddToOverlay(dialog, OverlayCategory.Modal);
// entry.Element — the dialog itself
// entry.Category — category
// entry.DismissOnClickOutside — false (can be set at creation)
// entry.OnDismiss — callback清理
overlay.ClearCategory(OverlayCategory.Tooltip); // remove all tooltips
overlay.ClearAll(); // remove everything便捷方法
对于常见模式,你不需要手动定位元素:
// Tooltip anchored to an element (position: "top" | "bottom" | "left" | "right").
// Added to the Tooltip category, dismissOnClickOutside: true.
overlay.ShowTooltip(anchor, tooltipContent, position: "top");
// Dropdown below an anchor; auto-flips above when there's no room. Dropdown category.
overlay.ShowDropdown(anchor, menuContent);
// One-time Escape handler: dismisses the topmost dismissable overlay. Idempotent.
overlay.InstallEscapeHandler();
// Trap Tab / Shift+Tab focus within a modal element.
overlay.InstallFocusTrap(myModalRoot);主题(主题能到达 overlay 吗?)
能。 OverlayHost 是屏幕根的最后一个 child,在 bootstrap 阶段(SusApp / SusBootstrap.GetOrCreateOverlay)会接收到完整的 design-token 级联,此外还有:
SusApp.UseCustomStyles(...)(品牌化)和UseFonts(...)会像应用到屏幕根一样应用到 OverlayHost,因此 popup 会匹配你的品牌颜色和字体。SusThemeService.Instance.SetTheme(root, theme)也会在 OverlayHost 以及每一个当前打开的 子 overlay 上切换.theme-*class,因此被重新挂载(reparented)的 popup 会以当前主题 重新绘制。
通常你什么都不需要做——通过 SusApp 挂载,overlay 会自动继承 token + 主题 + 品牌化。 手动搭建 bootstrap 时,必须在构建 overlay 之后调用 SetTheme,它才能应用非默认主题。
相关内容(Core)
| 部分 | 状态 | 备注 |
|---|---|---|
开发者控制台(SusConsoleService) | 已发布 | OverlayCategory.Console |
SusToast / toast 组件 | 计划中 | 等你实现它时使用 OverlayCategory.Toast / SusToastBase |
| World-space 血条 / 姓名牌 | 下游 UI 包 | 独立的 world-space 面板——在屏幕之下,不在 OverlayHost 中 |
API
public class OverlayHost : SusLayer // SusLayer : VisualElement
{
public OverlayEntry AddToOverlay(VisualElement element, OverlayCategory category,
bool dismissOnClickOutside = false, Action onDismiss = null);
public void RemoveFromOverlay(VisualElement element);
public void RemoveFromOverlay(OverlayEntry entry);
public void ClearCategory(OverlayCategory category);
public void ClearAll();
public int Count { get; }
public IReadOnlyList<OverlayEntry> Stack { get; }
public void DumpStack(); // #if UNITY_EDITOR || DEVELOPMENT_BUILD - diagnostics
}
public class OverlayEntry
{
public VisualElement Element;
public OverlayCategory Category;
public bool DismissOnClickOutside;
public Action OnDismiss;
}