Spatial UI — 空间用户界面

PICO Spatial UI 是基于 Jetpack Compose 构建的空间 UI 框架。它将标准 Compose 组件扩展到三维空间,支持眼动+手部交互、空间布局和 3D 内容集成。

NOTE

一句话概括 Spatial UI = Jetpack Compose(声明式 2D UI)+ 空间扩展(3D 布局、立体交互、空间容器)

1. 三层架构

层级Maven 坐标内容
UI Foundationspatial-ui-foundationDSL 入口、WindowContainer/Stage、SpatialView、手势、布局修饰符、3D 效果、悬停、触觉、AttachmentPanel
UI Designspatial-ui-designPicoTheme、全部 UI 组件库(Button/Text/Slider/Menu/Dialog 等 30+)、设计令牌
UI Platformspatial-ui-platformSpatialLaunchActivity、容器导航、长度单位转换、平台能力检测、Material 样式

2. PicoTheme — 空间主题

DefaultWindowContainer {
    PicoTheme {
        // 所有 UI 组件在此渲染
    }
}
 
// 自定义颜色方案
PicoTheme(colorScheme = defaultColorScheme()) { ... }
 
// 主题提供的设计令牌
PicoTheme.colorScheme.fillPrimary        // 填充色(主色)
PicoTheme.colorScheme.labelPrimaryLight  // 标签色(浅色)
PicoTheme.colorTokens.LabelPrimary       // 标签色(独立令牌)
PicoTheme.typography.bodyLarge           // 排版系统
PicoTheme.typography.titleMedium
PicoTheme.typography.display             // 展示大标题
PicoTheme.typography.headlineLarge       // 头条标题

3. 窗口容器完整 API

// DefaultWindowContainer — 最简单的平面窗口
DefaultWindowContainer {
    PicoTheme { HomePage() }
}
 
// WindowContainer — 完整配置
WindowContainer(
    id = "myWindow",
    form = Form.Volumetric,                    // Planar / Volumetric
    defaultSize = Size(800f, 600f, 800f),      // 宽/高/深度
    resizeType = ContainerResizeType.ContentSize,
    worldScale = WorldScale.Fixed,             // Fixed / Dynamic
    enableMaterialBackground = true            // 材质背景(玻璃效果)
) {
    PicoTheme { Content() }
}
 
// Stage — 全空间沉浸式
Stage(
    id = "mainStage",
    immersion = 80,                            // 0-100 沉浸度
    upperLimbRenderMode = UpperLimbRenderMode.HIDE
) {
    PicoTheme { FullSpaceRoom() }
}
 
// 容器间导航
@Composable
fun HomePage() {
    val navigator = LocalSpatialNavigator.current
 
    Button(onClick = {
        // 打开新窗口
        navigator.openWindowContainer(id = "detail", key = "item_001")
    })
 
    Button(onClick = {
        // 打开 Stage
        navigator.openStage("gameStage", StageStyle.Mixed)
    })
 
    Button(onClick = {
        // 关闭
        navigator.closeWindowContainer("detail")
        closeStage()  // 或直接调用 (在 Stage 内)
    })
}
 
// 容器间传参
navigator.openWindowContainer(
    id = "detail",
    key = "artwork_001",
    bundle = bundleOf("artworkId" to "001", "title" to "Ancient Vase")
)
 
// 目标容器中接收
@Composable
fun DetailPage() {
    val artworkId = bundle?.getString("artworkId") ?: return
    // 使用 artworkId 加载数据
}

3.1 WindowContainerParamsUpdater — 运行时改窗口参数

import com.pico.spatial.ui.platform.containers.WindowContainerParamsUpdater
import com.pico.spatial.ui.foundation.dsl.LocalSpatialNavigator
 
@Composable
fun WindowSettings() {
    val navigator = LocalSpatialNavigator.current
    val updater = WindowContainerParamsUpdater(navigator, "mainWindow")
 
    // 修改标题栏类型
    updater.setCaptionBarType(
        WindowContainerParamsUpdater.CaptionBarType.SMALL
        // SMALL / LARGE / NONE
    )
 
    // 修改缩放限制
    updater.setResizeRestriction(
        WindowContainerParamsUpdater.ResizeRestriction.NO_RESTRICTION
        // NO_RESTRICTION / FIXED_SIZE / MIN_MAX
    )
 
    // 修改体积窗口底座面板
    updater.setVolumeBasePanelType(
        WindowContainerParamsUpdater.VolumeBasePanelType.ALWAYS_ON
        // ALWAYS_ON / ON_INTERACTION / OFF
    )
}

4. UI 组件完整目录

PICO Spatial UI 提供了 30+ 个组件。以下是完整目录:

4.1 基础组件

// Button — 有多种尺寸变体
Button(onClick = { }) { Text("确定") }
Button(onClick = { }, enabled = false) { Text("不可用") }
Button(
    onClick = { },
    colors = ButtonDefaults.buttonColors(
        containerColor = PicoTheme.colorScheme.fillPrimary,
        contentColor = Color.White
    ),
    shape = ButtonDefaults.Small     // Min / Small / Regular / Max
) { Text("自定义") }
 
// IconButton
IconButton(onClick = { }) {
    Icon(painter = painterResource(R.drawable.ic_star), contentDescription = "收藏")
}
IconButton(
    onClick = { },
    colors = IconButtonDefaults.iconButtonColors(
        containerColor = Color.Vibrant.withVibrant(Vibrant.Darkest),
        contentColor = Color.White
    ),
    modifier = Modifier.size(IconButtonDefaults.iconButtonSize(48.dp))
)
 
// ToggleIconButton(开关图标按钮)
ToggleIconButton(checked = isFav, onCheckedChange = { }) {
    Icon(...)
}
 
// Text
Text("Hello PICO", style = PicoTheme.typography.titleLarge)
Text("描述文本", fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 2)
 
// Icon
Icon(painter = painterResource(R.drawable.ic_heart), contentDescription = null)
 
// Divider
Divider()

4.2 输入组件

// SearchField
SearchField(
    value = query,
    onValueChange = { query = it },
    onSearch = { performSearch(it) },
    placeholder = { Text("搜索艺术作品") },
    leadingIcon = { Icon(Icons.Default.Search, null) }
)
 
// TextField / TextArea
TextField(
    value = text,
    onValueChange = { text = it },
    placeholder = { Text("输入名称") },
    colors = TextFieldDefaults.textFieldColors()
)
TextArea(value = desc, onValueChange = { desc = it })
 
// NumberField(数字输入)
NumberField(value = count, onValueChange = { count = it }, editable = true)
 
// Slider
Slider(
    value = volume,
    onValueChange = { volume = it },
    valueRange = 0f..1f,
    modifier = Modifier.weight(1f),
    colors = SliderDefaults.sliderColors(
        thumbColor = Color.Vibrant.withVibrant(Vibrant.Darkest)
    )
)
 
// SegmentControl(分段选择器)
SegmentControl {
    items(segments) { segment ->
        SegmentItem(
            selected = selected == segment,
            onClick = { selected = segment }
        ) {
            Text(segment.label)
        }
    }
}
 
// Checkbox
Checkbox(
    checked = isChecked,
    onCheckedChange = { isChecked = it },
    contentSize = CheckboxContentSize.Small
)
 
// Switch
Switch(checked = isEnabled, onCheckedChange = { isEnabled = it })
 
// Option(选项按钮)
Option(selected = isSelected, onSelectChange = { }) { Text("选项") }
 
// Chip 系列
Chip(onClick = { }) { Text("标签") }
RemovableChip(checked = true, onCheckedChange = { }) { Text("可移除") }
ToggleableChip(checked = isFiltered, onCheckedChange = { }) { Text("筛选") }
ButtonChip(onClick = { }) { Text("操作") }

4.3 导航与容器

// TabBar
TabBar(followViewpoints = ViewPoint.FrontOnly) {
    item(selected = currentTab == 0, mainContent = { Text("推荐") },
         supportContent = { Text("为你推荐") }, onClick = { currentTab = 0 })
    item(selected = currentTab == 1, mainContent = { Text("分类") },
         supportContent = { Text("浏览分类") }, onClick = { currentTab = 1 })
}
 
// SideNavigation
SideNavigation {
    SideNavigationItem(selected = true, onClick = { }) { Text("首页") }
    SideNavigationItem(selected = false, onClick = { }) { Text("设置") }
}
 
// TitleBar
TitleBar(
    title = "虚拟画廊",
    titleAlignment = TitleAlignment.CenterInBar,
    leadingActions = { IconButton(onClick = { }) { Icon(...) } }
)
 
// Toolbar(底部工具栏)
Toolbar(cornerSize = 12.dp) {
    // 固定在底部的操作栏
}
 
// PageControl(分页指示器)
PageControl(
    currentIndex = currentPage,
    totalDots = 5,
    onClickAction = { page -> /* 跳转 */ },
    colors = PageControlDefaults.pageControlColors().copy(normalColor = Color.Gray)
)
 
// Badge(徽标)
Badge(badgeColor = Color.Red, radius = 8.dp, contentPadding = PaddingValues(4.dp)) {
    Text("3")
}
 
// Link(超链接)
Link(onClick = { openUrl("https://example.com") }) { Text("查看详情") }

4.4 弹窗与反馈

// AlertDialog
AlertDialog(
    title = { Text("确认删除?") },
    content = { Text("此操作不可撤销") },
    buttons = {
        Button(onClick = { dismiss() }) { Text("取消") }
        Button(onClick = { confirm(); dismiss() }) { Text("删除") }
    },
    onDismissRequest = { dismiss() }
)
 
// Sheet(底部弹出面板)
Sheet(
    leadingAction = { IconButton(onClick = { }) { Icon(...) } },
    title = { Text("详情") },
    bottom = { Button(onClick = { }) { Text("确认") } },
    onDismissRequest = { }
) {
    // 内容
}
 
// Snackbar(轻提示)
val snackbarHostState = LocalSnackbarHostState.current
Button(onClick = {
    coroutineScope.launch {
        snackbarHostState.show(message = "已添加到收藏夹")
    }
})
 
// SpatialPopup(空间弹窗)
SpatialPopup(
    onDismissRequest = { showPopup = false },
    popupPositionProvider = rememberSpatialPopupPositionProvider(
        verticalPlacement = VerticalPlacement.above(8.dp),
        horizontalPlacement = HorizontalPlacement.alignStart()
    )
) {
    // 弹窗内容
}
 
// Menu
Menu {
    MenuItem(onClick = { }) { Text("编辑") }
    MenuItem(onClick = { }) { Text("分享") }
    MenuItem(onClick = { }) { Text("删除") }
}

4.5 展示组件

// ProgressIndicator
LinearProgressIndicator(progress = 0.5f, height = 4.dp)
CircularProgressIndicator()
 
// Subwindow(子窗口)
Subwindow {
    Text("这是一个子窗口内容")
}
 
// Coachmark(新手引导)
CoachmarkBox { Text("点击这里开始") }
RichCoachmark(
    title = { Text("欢迎!") },
    content = { Text("这是引导说明") },
    image = { Icon(...) },
    buttons = {
        CoachmarkDefaults.CoachmarkButton(onClick = { }) { Text("下一步") }
    }
)
 
// DatePicker
val dateState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis())
DatePicker(
    state = dateState,
    onDateSelected = { millis -> /* 选择日期 */ },
    headerStyle = HeaderStyle.Dropdown
)

5. 3D 内容集成

5.1 SpatialView — 嵌入 3D 场景

@Composable
fun GameView() {
    SpatialView(
        modifier = Modifier.fillMaxWidth().height(500.dp),
        // initial: 初始化 3D 内容(只执行一次)
        initial = { content: SpatialViewContent ->
            val entity = Entity.load("models/robot.glb")
            content.addEntity(entity)
        },
        // update: 每帧更新
        update = { content, deltaTime ->
            // 每帧逻辑
        },
        // attachments: 在 3D 场景上叠加 2D UI
        attachments = {
            AttachmentPanel {
                Button(onClick = { shoot() }) {
                    Text("射击")
                }
            }
        }
    )
}

5.2 SpatialViewContent API

// content 可用方法:
content.addEntity(entity)                // 添加实体到场景
content.removeEntity(entity)             // 从场景移除
 
// 订阅事件(系统级、碰撞、动画、音频等)
content.subscribe(CollisionEvents.Enter::class.java) { event -> }
content.subscribeAnimationEvents { event -> }
content.subscribe(AudioEvents.PlaybackCompleted::class.java) { event -> }
 
// 加载模型资源
val modelRes = content.loadModelResource("models/vase.usdz")
 
// 坐标空间转换
content.convertRotation(rotation, from = ViewCoordinateSpace.Global, to = content.localSpatialCoordinateSpace)

5.3 SpatialModelView — 快捷 3D 模型展示

@Composable
fun ModelCard() {
    // 从 AssetBundle 加载 model
    SpatialModelView(
        source = Source.bundle("Models/MyModel") {
            runBlocking { assetBundle.await() }
        },
        modifier = Modifier.size(200.dp, 250.dp),
        resizability = Resizability.FitInside
    ) { state ->
        // state 是 ModelLoadingState 的 sealed class
        when (state) {
            is ModelLoadingState.Loading -> CircularProgressIndicator()
            is ModelLoadingState.Success -> {
                // 模型已加载,可以添加交互
                Model(state.model)
            }
            is ModelLoadingState.Error -> Text("加载失败")
        }
    }
}

5.4 AttachmentPanel — 3D 附着 2D UI

// 在 SpatialView 中将 2D UI 附着到 3D 实体上
SpatialView(
    initial = { content ->
        val entity = Entity.load("models/cube.glb")
        entity.setName("target_cube")
        content.addEntity(entity)
    },
    attachments = {
        // 附着到名为 "target_cube" 的实体上
        AttachmentPanel(
            id = "label",
            entity = attachments.entity("target_cube"),
            alignment = AttachmentPanelComponent.Alignment.TOP_CENTER
        ) {
            Text("这是一个立方体", style = PicoTheme.typography.bodySmall)
        }
    }
)

6. 3D 手势交互

@Composable
fun GestureDemo() {
    var scale by remember { mutableFloatStateOf(1f) }
    var rotationY by remember { mutableFloatStateOf(0f) }
 
    SpatialView(
        initial = { content ->
            val entity = Entity.load("models/vase.glb")
            entity.setName("vase")
            // 添加 InteractableComponent 标记为可交互
            entity.components.set(InteractableComponent())
            content.addEntity(entity)
        },
        modifier = Modifier
            .fillMaxWidth()
            .height(400.dp)
            // 3D 点击手势
            .pointerInput(Unit) {
                detectSpatialTapGesture(
                    context = this,
                    target = TargetEntity.any { it.getName() == "vase" }
                ) { hitEntity, hitPosition ->
                    // 点击了花瓶
                }
            }
            // 3D 拖拽手势
            .pointerInput(Unit) {
                detectSpatialDragGesture(
                    context = this,
                    target = TargetEntity.any { it.getName() == "vase" }
                ) { dragEntity, delta ->
                    // 拖拽 delta 增量
                }
            }
            // 3D 缩放手势
            .pointerInput(Unit) {
                detectSpatialScaleGesture(
                    context = this,
                    onScale = { scaleFactor ->
                        scale *= scaleFactor
                    }
                )
            }
    )
}
 
// 也可以直接在 SpatialView 上附加手势检测(不指定 target 则检测所有)
SpatialView(
    modifier = Modifier.pointerInput(Unit) {
        detectSpatialTapGesture(context = this) { entity, position ->
            // 点击了场景中的任意实体
        }
    }
)

6.1 InteractionKind — 区分交互方式

import com.pico.spatial.ui.foundation.gesture.data.InteractionKind
 
// 在手势回调中获取交互方式
Modifier.pointerInput(Unit) {
    detectSpatialTapGesture { hitEntity, hitPosition, interactionKind ->
        when (interactionKind) {
            InteractionKind.RayBasedPinch -> { /* 手柄射线点击 */ }
            InteractionKind.DirectPinch -> { /* 手部直接触摸 */ }
            InteractionKind.GazePinch -> { /* 凝视选中 */ }
            InteractionKind.Poke -> { /* 手指戳 */ }
            InteractionKind.Pointer -> { /* 指针设备 */ }
        }
    }
}
 
// 5 种交互方式适用场景
// RayBasedPinch — 手柄默认,远距离操作
// DirectPinch  — 手部追踪时直接抓取
// GazePinch    — 眼动注视 + 手部确认
// Poke         — 近距离戳点(按钮)
// Pointer      — 测试/开发工具

7. 空间修饰符

PICO Spatial UI 提供了一系列 Compose 修饰符,让 2D UI 在 3D 空间中正确显示:

修饰符作用Demo 示例
offset(x, y)2D 偏移Modifier.offset(100.dp, 0.dp)
offset(z)3D 深度偏移(在 Z 轴方向移动)Modifier.offset(300.dp) ← 注意:单个参数 = Z 轴
requiredDepth(d)约束内容深度Modifier.requiredDepth(100.dp)
rotate3D { rotation3D }3D 旋转Modifier.rotate3D { Offset3D(0f, rotation, 0f).toRotation3D() }
scale3d { }3D 缩放Modifier.scale3D { Offset3D(s, s, s).toScale3D() }
spatialFloating()Z 轴浮动悬浮Modifier.spatialFloating(height = 20.dp)
backgroundMaterial()材质背景(毛玻璃)Modifier.backgroundMaterial(style = Material.Thick)
spatialHoverEffect()悬停高亮效果(手部靠近时)Modifier.spatialHoverEffect()
vibrantEffect(vibrant)鲜艳/玻璃效果Modifier.vibrantEffect(Vibrant.SemiLight)
windowConstraints(w, h)约束窗口尺寸Modifier.windowConstraints(width = 400.dp)
tooltip(text)悬停提示Modifier.tooltip("点击收藏")
semantics { contentDescription }无障碍语义Modifier.semantics { contentDescription = "收藏按钮" }

7.1 空间定位修饰符

// Z 轴深度偏移
@Composable
fun DepthExample() {
    Row {
        Box(Modifier.offset(z = 50.dp).requiredDepth(30.dp).backgroundMaterial()) {
            Text("近层")
        }
        Box(Modifier.offset(z = 0.dp).requiredDepth(30.dp).backgroundMaterial()) {
            Text("中层")
        }
        Box(Modifier.offset(z = -50.dp).requiredDepth(30.dp).backgroundMaterial()) {
            Text("远层")
        }
    }
}
 
// spatialFloating — 悬浮感
@Composable
fun FloatingCard() {
    Card(
        modifier = Modifier.size(150.dp, 200.dp)
            .spatialFloating(height = 30.dp)
            .backgroundMaterial()
    ) {
        Text("悬浮卡片", modifier = Modifier.padding(12.dp))
    }
}

7.2 3D 变换修饰符

// rotate3D — Y 轴翻转
@Composable
fun RotatingCard(tiltAngle: Float) {
    Box(
        modifier = Modifier.size(200.dp, 120.dp)
            .backgroundMaterial()
            .rotate3D { Offset3D(x = 0f, y = tiltAngle, z = 0f).toRotation3D() }
    ) {
        Text("3D 翻转", style = PicoTheme.typography.titleMedium)
    }
}
 
// scale3d — Z 轴空间缩放
@Composable
fun ScaleOnHover(isHovered: Boolean) {
    val s = if (isHovered) 1.2f else 1.0f
    Box(
        modifier = Modifier.size(100.dp)
            .backgroundMaterial()
            .scale3D { Offset3D(x = s, y = s, z = s).toScale3D() }
            .spatialHoverEffect()
    ) {
        Text("悬停放大")
    }
}
 
// 组合使用
@Composable
fun GlassCard() {
    Box(
        modifier = Modifier.size(200.dp, 100.dp)
            .requiredDepth(50.dp)
            .spatialFloating(height = 20.dp)
            .backgroundMaterial(style = Material.Thick)
            .spatialHoverEffect()
            .vibrantEffect(Vibrant.SemiLight)
            .rotate3D { Offset3D(x = 0f, y = tiltAngle, z = 0f).toRotation3D() }
    ) {
        Text("悬浮卡片", style = PicoTheme.typography.bodyLarge)
    }
}

7.3 视觉效果修饰符

// backgroundMaterial — 3 种毛玻璃厚度
@Composable
fun MaterialDemo() {
    Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
        Box(Modifier.size(80.dp).backgroundMaterial(Material.Thin)) {
            Text("薄")
        }
        Box(Modifier.size(80.dp).backgroundMaterial(Material.Regular)) {
            Text("中")
        }
        Box(Modifier.size(80.dp).backgroundMaterial(Material.Thick)) {
            Text("厚")
        }
    }
}
 
// spatialHoverEffect — 手部靠近高亮
@Composable
fun HoverButton() {
    Button(onClick = { }, modifier = Modifier.spatialHoverEffect()) {
        Text("悬停高亮")
    }
}
 
// vibrantEffect — PICO 颜色层级
@Composable
fun VibrantPanel() {
    Surface(modifier = Modifier.size(200.dp, 60.dp).vibrantEffect(Vibrant.Darkest)) {
        Text("Darkest")
    }
    Surface(modifier = Modifier.size(200.dp, 60.dp).vibrantEffect(Vibrant.SemiLight)) {
        Text("SemiLight")
    }
    Surface(modifier = Modifier.size(200.dp, 60.dp).vibrantEffect(Vibrant.Neutral)) {
        Text("Neutral")
    }
}
 
// PICO 鲜艳颜色系统
Color.Vibrant              // 基础鲜艳色
Color.Vibrant.withVibrant(Vibrant.Darkest)
Color.Vibrant.withVibrant(Vibrant.Darker)
Color.Vibrant.withVibrant(Vibrant.SemiLight)
Color.Vibrant.withVibrant(Vibrant.Light)
Color.Vibrant.withVibrant(Vibrant.Neutral)

7.4 交互增强修饰符

// tooltip — 悬停提示
@Composable
fun TooltipButton() {
    IconButton(onClick = { }, modifier = Modifier.tooltip("收藏")) {
        Icon(painter = painterResource(R.drawable.ic_heart), contentDescription = "收藏")
    }
}
 
// windowConstraints — 约束内容宽度
@Composable
fun ConstrainedPanel() {
    Column(modifier = Modifier.fillMaxWidth().windowConstraints(width = 400.dp)) {
        // 内容自动适配
    }
}

8. 平台工具

// dp 到世界空间米的转换
val converter = LocalPhysicalLengthConverter.current
val worldMeters = converter.dpToLength(100.dp, LengthUnit.Meters)
 
// 设备能力检测
if (SpatialBuild.isSpatialPlatform()) {
    // 在 PICO 设备上运行
} else {
    // 在普通 Android 设备上(可能无法使用空间功能)
}
 
// Material 样式枚举
Material.Thick    // 厚材质(更不透明)
Material.Thin     // 薄材质(更透明)
Material.Regular  // 普通

9. spatialui Demo 架构

spatialui-0.13.3/
└── app/src/main/java/com/pico/spatial/sample/spatialui/
    ├── Main.kt                   — DefaultWindowContainer + PicoTheme 入口
    ├── ui/
    │   ├── HomePage.kt           — TabBar + NavHost 导航
    │   ├── pages/
    │   │   ├── FeedsPage.kt      — HorizontalPager + LazyVerticalGrid + Badge
    │   │   ├── SearchPage.kt     — SearchField + Chip 筛选 + LazyVerticalGrid
    │   │   └── ContentDetailPage.kt — Snackbar + Toolbar + Menu + Subwindow
    │   ├── viewmodel/
    │   │   ├── FeedsViewModel.kt — StateFlow + 数据管理
    │   │   └── SearchViewModel.kt — 搜索 + 分类过滤
    │   └── model/DataModel.kt    — data class 模型定义
    └── ktx/Extensions.kt         — 扩展函数
 
welcomespace-0.13.3/ (3D 交互学习最佳参考)
    ├── Main.kt                   — WindowContainer + Stage 多容器
    ├── ecs/                      — 自定义 System + Component
    └── ui/
        ├── FullSpaceRoom.kt      — Stage 全空间 + SpatialView + ShaderGraph
        ├── ItemDisplayVolume.kt  — Volumetric 窗口 + 手势交互 + 碰撞
        ├── ItemModelCard.kt      — SpatialModelView + dragGesture + rotate3D
        └── SettingContent.kt     — DatePicker + Sheet + AlertDialog + Checkbox

10. 交互反馈 API

10.1 SpatialSoundEffect — 系统音效

无需加载音频文件即可播放的 12 种预定义 UI 音效。

import com.pico.spatial.ui.platform.SpatialSoundEffect
import com.pico.spatial.ui.platform.playSystemSound
 
@Composable
fun SoundButton() {
    Button(onClick = { playSystemSound(SpatialSoundEffect.CONFIRM) }) {
        Text("确认")
    }
}
 
// 常用音效对照
SpatialSoundEffect.CONFIRM       // 按钮点击、操作成功
SpatialSoundEffect.CANCEL        // 关闭弹窗、取消操作
SpatialSoundEffect.ERROR         // 表单验证失败、操作不允许
SpatialSoundEffect.TOGGLE_ON     // 开关 — 开
SpatialSoundEffect.TOGGLE_OFF    // 开关 — 关
SpatialSoundEffect.SLIDE         // Slider 拖动
SpatialSoundEffect.PICK          // 拾取 3D 物体
SpatialSoundEffect.DROP          // 放下 3D 物体
SpatialSoundEffect.NOTIFICATION  // 通知提示
SpatialSoundEffect.PAGE_CHANGE   // 页面切换

10.2 跨窗口拖放(Drag-and-Drop)

在 WindowContainer 之间拖拽 3D 物体或 UI 元素。

// "拖出"源窗口
SpatialView(
    modifier = Modifier.detectSpatialDragGesture(
        context = this,
        target = TargetEntity.any { it.getName() == "draggable" }
    ) { entity, delta ->
        // 拖拽过程中每帧更新位置
        entity.setPosition(entity.position + delta)
    }
)
 
// 跨窗口场景:源窗口拖出 → 目标窗口接收
// 1. 源窗口内开始拖拽,同时标记拖拽状态
// 2. 通过 navigator 在目标窗口显示放置指示器
// 3. 释放时通过 EventBus / shared ViewModel 传递 Entity 引用
 
val navigator = LocalSpatialNavigator.current
val sharedModel: GalleryViewModel = koinViewModel()
 
// 源窗口:开始拖拽
Modifier.detectSpatialDragGesture { entity, delta ->
    sharedModel.setDragging(true)
    // 显示跟随拖拽位置的预览实体
    previewEntity.setPosition(previewEntity.position + delta)
}
 
// 目标窗口:接收放置
Modifier.detectSpatialTapGesture { hitEntity, position ->
    if (sharedModel.isDragging) {
        // 在放置位置创建新实体
        val droppedEntity = Entity.load("models/item.glb")
        droppedEntity.setPosition(position)
        content.addEntity(droppedEntity)
        sharedModel.setDragging(false)
    }
}

11. 组件速查 — Top 10 实战用法

以下展示最常用的 10 个空间 UI 组件在真实布局中的用法,可以直接复制作为页面模板起点。

// ─── 1. Settings Page 模板 ───
// 展示 Button / Text / Slider / Checkbox / Switch 组合使用
@Composable
fun SettingsPage() {
    var volume by remember { mutableFloatStateOf(0.5f) }
    var isVibration by remember { mutableStateOf(true) }
    var isMusic by remember { mutableStateOf(false) }
 
    Column(modifier = Modifier.padding(24.dp).fillMaxWidth()) {
        // 标题
        Text("设置", style = PicoTheme.typography.Display)
 
        Spacer(Modifier.height(24.dp))
 
        // 音量滑块
        Text("音量", style = PicoTheme.typography.titleMedium)
        Row(verticalAlignment = Alignment.CenterVertically) {
            Icon(painter = painterResource(R.drawable.ic_volume), null)
            Slider(
                value = volume,
                onValueChange = { volume = it },
                valueRange = 0f..1f,
                modifier = Modifier.weight(1f)
            )
        }
        // 显示当前值
        Text("${(volume * 100).toInt()}%", style = PicoTheme.typography.bodySmall)
 
        Spacer(Modifier.height(16.dp))
 
        // 开关选项
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text("震动反馈", modifier = Modifier.weight(1f))
            Switch(checked = isVibration, onCheckedChange = { isVibration = it })
        }
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text("背景音乐", modifier = Modifier.weight(1f))
            Switch(checked = isMusic, onCheckedChange = { isMusic = it })
        }
 
        Spacer(Modifier.height(24.dp))
        HorizontalDivider()
        Spacer(Modifier.height(16.dp))
 
        // 分段选择器
        Text("画面模式", style = PicoTheme.typography.titleMedium)
        val modes = listOf("标准", "鲜艳", "护眼")
        var selectedMode by remember { mutableStateOf(0) }
        SegmentControl {
            modes.forEachIndexed { index, label ->
                SegmentItem(
                    selected = index == selectedMode,
                    onClick = { selectedMode = index }
                ) { Text(label) }
            }
        }
 
        Spacer(Modifier.height(32.dp))
 
        // 操作按钮
        Button(onClick = { /* 保存 */ },
            modifier = Modifier.fillMaxWidth(),
            shape = ButtonDefaults.Regular
        ) { Text("保存设置") }
    }
}
 
// ─── 2. Search Bar 模板 ───
@Composable
fun SearchPage() {
    var query by remember { mutableStateOf("") }
    var selectedCategory by remember { mutableStateOf("全部") }
    val categories = listOf("全部", "图片", "视频", "模型")
 
    Column(modifier = Modifier.padding(16.dp)) {
        SearchField(
            value = query,
            onValueChange = { query = it },
            onSearch = { performSearch(it) },
            placeholder = { Text("搜索作品...") },
            leadingIcon = { Icon(Icons.Default.Search, null) }
        )
 
        Spacer(Modifier.height(12.dp))
        // Chip 筛选
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            categories.forEach { cat ->
                Chip(
                    selected = cat == selectedCategory,
                    onClick = { selectedCategory = cat }
                ) { Text(cat) }
            }
        }
    }
}
 
// ─── 3. Dialog + Snackbar 模板 ───
@Composable
fun DeleteConfirmDialog(
    onDismiss: () -> Unit,
    onConfirm: () -> Unit
) {
    val snackbarHostState = LocalSnackbarHostState.current
    val scope = rememberCoroutineScope()
 
    AlertDialog(
        title = { Text("确认删除?") },
        content = { Text("删除后无法恢复此作品。") },
        buttons = {
            Button(onClick = onDismiss) { Text("取消") }
            Button(onClick = {
                onConfirm()
                scope.launch {
                    snackbarHostState.show(message = "已删除")
                }
            }) { Text("删除") }
        },
        onDismissRequest = onDismiss
    )
}
 
// ─── 4. TabBar 导航模板 ───
@Composable
fun MainTabs() {
    var currentTab by remember { mutableIntStateOf(0) }
 
    Column {
        TabBar(followViewpoints = ViewPoint.FrontOnly) {
            item(selected = currentTab == 0,
                 mainContent = { Text("推荐") },
                 onClick = { currentTab = 0 })
            item(selected = currentTab == 1,
                 mainContent = { Text("分类") },
                 onClick = { currentTab = 1 })
            item(selected = currentTab == 2,
                 mainContent = { Text("我的") },
                 onClick = { currentTab = 2 })
        }
 
        // 根据 currentTab 显示不同内容
        when (currentTab) {
            0 -> Text("推荐内容")
            1 -> Text("分类浏览")
            2 -> Text("个人中心")
        }
    }
}

附录:空间 UI 设计规范速查

以下是最关键的 4 条设计规范,详细内容见 第20课:空间 UI 设计规范

规则要点后果
1. 使用 PicoTheme 包裹所有容器根节点用 PicoTheme { ... }样式不一致
2. 内置组件优先优先使用 com.pico.spatial.ui.design.*不必要的自定义工作
3. hover 用 spatialHoverEffect禁止使用 hoverable + animateFloatAsState行为与系统不一致
4. 窗口使用系统玻璃材质根节点不加 Modifier.background()破坏玻璃视觉效果

手势冲突规则

// ❌ 错误:多个 detectSpatial* 在同一个 pointerInput 块
Modifier.pointerInput(Unit) {
    detectSpatialDragGesture { ... }
    detectSpatialRotateGesture { ... }  // 不会生效
}
 
// ✅ 正确:复合手势用一个识别器
Modifier.pointerInput(Unit) {
    detectSpatialTransformGesture { event ->
        // 同时处理旋转/缩放/拖动
    }
}

E 中的交互前置条件

交互类型必须组件
程序化命中测试 (RayCast)CollisionComponent
用户交互 (注视/点击/拖拽)CollisionComponent + InteractableComponent
注视反馈高亮额外加 HoverEffectComponent

快速练习

查看 PICOProject/welcomespace-0.13.3/app/.../ui/

  1. ItemModelCard.kt 中找到 detectSpatialDragGesturerotate3D 修饰符的使用
  2. ItemDisplayVolume.kt 中找到 detectSpatialScaleGestureInteractableComponent
  3. SettingContent.kt 中找到 DatePickerCheckboxAlertDialog 的使用

INFO

参考资料

  • 本地 API 文档:pico-sdk-0.13.3-mirror/spatial-api/0.13.3/spatialui/
  • 最佳 Demo 参考:PICOProject/welcomespace-0.13.3/(3D 交互 + 容器 + 手势)
  • UI 组件库:PICOProject/spatialui-0.13.3/(30+ 组件示例)
  • 官方 UI 组件库文档:downloads/spatial-sdk/markdown/031-spatial-ui-component-library.md
  • 官方手势指南:downloads/spatial-sdk/markdown/057-spatial-gestures.md

上一课: 第11课:Spatial Tracking | 下一课: 第13课:其他模块