空间界面里能放东西的地方看似无限,实际上有一圈硬边界:看得清的、够得着的、待着不累的,只有一个有限的区域。把内容丢到视野边缘或者手臂够不着的地方,用户就得转头、伸手、扭腰,体验立刻掉档。这篇讲三件事------视锥怎么限制可见范围、舒适区怎么界定体感、可达性怎么决定交互成本,最后给一份能直接用的排布参数表。
视锥:看得见不等于看得清
人眼不是均匀感光的。视野可以粗略分成三圈:
| 区域 | 视角范围(相对视线中心) | 能力 | 适合放什么 |
|---|---|---|---|
| 中心凹视野 | 约 ±5° | 最高分辨率、能读细节 | 当前焦点、正文、关键数字 |
| 舒适注视野 | 约 ±30° | 清晰可读,转头幅度小 | 主要内容、卡片、按钮 |
| 周边视野 | ±30° ~ ±60° | 能感知、难辨认 | 次要提示、状态、氛围 |
| 边缘视野 | > ±60° | 有动静才注意 | 不建议放需要读的信息 |
垂直方向的可用范围比水平方向窄,而且不对称:向上看的舒适角度比向下小,抬头久了脖子会酸。所以顶部内容的可用高度要留得更保守。
这些角度决定了主内容区不能铺太开。超过 ±30° 的东西,用户得转头才能看清,一转,原来的空间参照就丢了。稳妥的做法是:把必须读的内容压在中心 ±30° 内,把可感知不可读的内容放到 ±30°~60°,再往外只放氛围和背景。
舒适区:距离决定体验
同一块内容,放远放近,体验差别很大。按距离可以把空间分成几段:
| 区域 | 视距参考 | 用途 | 交互方式 |
|---|---|---|---|
| 近场阅读区 | 0.35 ~ 0.6 m | 读正文、看细节、输入 | 视线 + 手势,精度要求高 |
| 操作区 | 0.6 ~ 1.2 m | 按钮、列表、卡片点选 | 手势、控制器 |
| 浏览区 | 1.2 ~ 2.0 m | 浏览大图、卡片墙、场景 | 视线、转向 |
| 环境区 | 2.0 m 以上 | 背景、氛围、空间锚点 | 一般不交互 |
0.35m 以内太近,眼睛要用力对焦,容易疲劳;1.2m 以上手势精度会明显下降,因为手在空中画弧,抖动被放大。交互目标越精细,越要往近场放;越只是"看着"的,越可以往远放。
手部可达性还要单独算一笔账。人手臂伸直大约 0.6~0.7m,舒服的操作范围是前方偏下 0.4~0.6m、水平 ±30° 的锥形区里,肩膀不动就能覆盖。超出这个范围的动作,要么耸肩,要么转体,长时间会累。频繁操作的元素(确认、返回、切换),位置要固定在这个可达锥里,别指望用户每次都大臂一挥。
距离、角度与可读性
内容在视野里占多大,取决于两个量:它自身的物理尺寸,以及它到眼睛的距离。二者之比就是视角:
tan(视角/2) = 元素尺寸 / (2 × 视距)
推到实用结论:元素尺寸要随视距线性放大。距离翻倍,尺寸也得大致翻倍,否则视觉大小减半、可读性掉档。人眼舒适阅读的字符视角大约在 0.3°~1° 之间,低于 0.3° 就费劲了。
这条规律对透视布局很关键:用透视把内容推到远处(z 变小)时,若不同步做大,它就会缩成看不清的一小块。只要"物理尺寸 / 视距"这个比值稳定,远近内容就能保持一致的视觉大小。
Fitts 定律在 3D 里的延伸
Fitts 定律原本描述 2D 点选:到达目标的时间 T 与目标距离 D 和有效尺寸 W 有关,
T = a + b × log2(2D / W)
距离越远、目标越小,就越慢越容易错。到了 3D,这条规律仍成立,但多了几个变量:
- D 是三维距离。不光左右上下,前后也算。一个"没那么远、但偏后"的目标,移动成本并不低。
- W 受视角影响。同样的物理尺寸,放远了有效命中区域就小,等效于 W 变小。
- 指向有深度误差。手在空中的前后抖动最大,深度方向上的精度最差,所以 3D 目标在 z 方向要留更大容错。
- 命中方式变了。视线点选有视线抖动,手势点选有手部抖动,两者叠加时误差更大。
实用推论:3D 目标要比 2D 更大,尤其是在深度方向。 常见的补偿手段有:给目标加吸附范围、把常用操作固定在近场、用"够得着就够得着"的软吸附(接近即命中),而不是要求像素级对准。
空间排布参数建议表
下面这份表汇总了上面几条原则,可以直接当作设计和评审的默认基线。数值是常规区间,具体项目按设备形态(头显、眼镜、裸眼 3D)微调。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 主内容水平视角 | ±30° 内 | 超出后转头成本明显上升 |
| 主内容垂直视角 | 向上 +20°,向下 -25° | 向上舒适度更差 |
| 周边提示视角 | ±30° ~ ±60° | 只放可感知信息 |
| 正文阅读视距 | 0.4 ~ 0.6 m | 近场,配大字号 |
| 交互目标视距 | 0.5 ~ 1.0 m | 手势精度可接受 |
| 浏览内容视距 | 1.2 ~ 2.0 m | 看为主,少交互 |
| 环境背景视距 | 2.0 m 以上 | 不做交互 |
| 最小可点击目标 | ≥ 48 × 48 vp(近场) | 远场按视距等比放大 |
| 深度容错(z 方向) | ≥ 目标尺寸的 0.3 倍 | 深度方向抖动最大 |
| 单屏主内容数量 | 4 ~ 7 个 | 与工作记忆容量匹配 |
| 场景切换移动距离 | 尽量 ≤ 60° 转头 | 超过需给过渡提示 |
按视锥和舒适区排布一组卡片
把上面的规则写成代码。核心是两个辅助函数:一个把视角换算成该视距下的物理尺寸,一个判断某个位置是否落在舒适锥内。
typescript
// SpatialLayout.ets ------ 按视锥与舒适区约束排布卡片
import { matrix4 } from '@kit.ArkUI';
// 常用约束常量,集中管理方便评审时对照
const FOV = {
focus: 30, // 舒适注视野半角(度)
peripheral: 60, // 周边视野半角(度)
verticalUp: 20, // 向上舒适半角
verticalDown: 25 // 向下舒适半角
}
// 依据"视角恒定"原则:给定视距与目标视角,算出应设置的尺寸
// 保证内容在不同深度上视觉大小一致
function sizeForViewAngle(distanceM: number, viewAngleDeg: number): number {
const rad = viewAngleDeg * Math.PI / 180
// 返回米,调用方再换算为 vp 或毫米
return 2 * distanceM * Math.tan(rad / 2)
}
// 判断某个极坐标位置是否落在水平舒适视锥内
function inComfortCone(angleDeg: number): boolean {
return Math.abs(angleDeg) <= FOV.focus
}
// 判断是否落在可见(可感知)范围
function inVisibleCone(angleDeg: number): boolean {
return Math.abs(angleDeg) <= FOV.peripheral
}
@Entry
@Component
struct SpatialCardWall {
// 卡片数据:角度偏移(度) + 视距(米)
private cards: Array<{ title: string; angle: number; dist: number }> = [
{ title: '推荐', angle: -24, dist: 1.4 },
{ title: '关注', angle: -8, dist: 1.4 },
{ title: '首页', angle: 8, dist: 1.4 },
{ title: '历史', angle: 24, dist: 1.4 },
{ title: '提示', angle: 52, dist: 2.2 }, // 周边视野,只做感知
{ title: '设置', angle: 78, dist: 2.2 } // 超出周边,仅氛围
]
// 1 米视距大约对应多少 vp,用于把物理尺寸折算成布局单位
private readonly vpPerMeter: number = 900
build() {
Stack({ alignContent: Alignment.Center }) {
ForEach(this.cards, (card: { title: string; angle: number; dist: number }) => {
// 依据视距算出该卡片的宽度:越远越大,视觉大小保持一致
const widthVp = sizeForViewAngle(card.dist, 12) * this.vpPerMeter
// 依据角度换算水平摆放位置
const xVp = card.dist * this.vpPerMeter * Math.sin(card.angle * Math.PI / 180)
Column() {
Text(card.title)
.fontSize(16)
.fontColor(Color.White)
}
.width(widthVp)
.height(widthVp * 0.62)
.borderRadius(16)
// 背景用深色,保证文字对比
.backgroundColor('#1E2740')
// 转到该角度对应的朝向,同时让卡片朝向用户
.rotate({ angle: card.angle, centerX: '50%', centerY: '600%', perspective: 1400 })
.translate({ x: xVp })
// 超出舒适锥的卡片降权,只做提示
.opacity(inComfortCone(card.angle) ? 1.0 : 0.55)
.blur(invisibleOverride(card.angle))
}, (card: { title: string; angle: number; dist: number }) => card.title)
}
.width('100%')
.height('100%')
.backgroundColor('#0B1020')
}
}
// 超出可见范围的卡片给一点模糊,暗示"不在当前视野内"
function invisibleOverride(angleDeg: number): number {
return inVisibleCone(angleDeg) ? 0 : 6
}
这里用"角度定朝向、视距定大小、视角恒定保证视觉一致"三件事配合,卡片围成一圈,转头就能切换视野,而内容始终是清晰可读的。
#mermaid-svg-xKyDVyXV7jb7ycdd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xKyDVyXV7jb7ycdd .error-icon{fill:#552222;}#mermaid-svg-xKyDVyXV7jb7ycdd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xKyDVyXV7jb7ycdd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xKyDVyXV7jb7ycdd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xKyDVyXV7jb7ycdd .marker.cross{stroke:#333333;}#mermaid-svg-xKyDVyXV7jb7ycdd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xKyDVyXV7jb7ycdd p{margin:0;}#mermaid-svg-xKyDVyXV7jb7ycdd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xKyDVyXV7jb7ycdd .cluster-label text{fill:#333;}#mermaid-svg-xKyDVyXV7jb7ycdd .cluster-label span{color:#333;}#mermaid-svg-xKyDVyXV7jb7ycdd .cluster-label span p{background-color:transparent;}#mermaid-svg-xKyDVyXV7jb7ycdd .label text,#mermaid-svg-xKyDVyXV7jb7ycdd span{fill:#333;color:#333;}#mermaid-svg-xKyDVyXV7jb7ycdd .node rect,#mermaid-svg-xKyDVyXV7jb7ycdd .node circle,#mermaid-svg-xKyDVyXV7jb7ycdd .node ellipse,#mermaid-svg-xKyDVyXV7jb7ycdd .node polygon,#mermaid-svg-xKyDVyXV7jb7ycdd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xKyDVyXV7jb7ycdd .rough-node .label text,#mermaid-svg-xKyDVyXV7jb7ycdd .node .label text,#mermaid-svg-xKyDVyXV7jb7ycdd .image-shape .label,#mermaid-svg-xKyDVyXV7jb7ycdd .icon-shape .label{text-anchor:middle;}#mermaid-svg-xKyDVyXV7jb7ycdd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xKyDVyXV7jb7ycdd .rough-node .label,#mermaid-svg-xKyDVyXV7jb7ycdd .node .label,#mermaid-svg-xKyDVyXV7jb7ycdd .image-shape .label,#mermaid-svg-xKyDVyXV7jb7ycdd .icon-shape .label{text-align:center;}#mermaid-svg-xKyDVyXV7jb7ycdd .node.clickable{cursor:pointer;}#mermaid-svg-xKyDVyXV7jb7ycdd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xKyDVyXV7jb7ycdd .arrowheadPath{fill:#333333;}#mermaid-svg-xKyDVyXV7jb7ycdd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xKyDVyXV7jb7ycdd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xKyDVyXV7jb7ycdd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xKyDVyXV7jb7ycdd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xKyDVyXV7jb7ycdd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xKyDVyXV7jb7ycdd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xKyDVyXV7jb7ycdd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xKyDVyXV7jb7ycdd .cluster text{fill:#333;}#mermaid-svg-xKyDVyXV7jb7ycdd .cluster span{color:#333;}#mermaid-svg-xKyDVyXV7jb7ycdd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xKyDVyXV7jb7ycdd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xKyDVyXV7jb7ycdd rect.text{fill:none;stroke-width:0;}#mermaid-svg-xKyDVyXV7jb7ycdd .icon-shape,#mermaid-svg-xKyDVyXV7jb7ycdd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xKyDVyXV7jb7ycdd .icon-shape p,#mermaid-svg-xKyDVyXV7jb7ycdd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xKyDVyXV7jb7ycdd .icon-shape .label rect,#mermaid-svg-xKyDVyXV7jb7ycdd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xKyDVyXV7jb7ycdd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xKyDVyXV7jb7ycdd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xKyDVyXV7jb7ycdd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
放不下
通过
列出内容并排序优先级
视角是否在 ±30 度内
放舒适注视野:可读可交互
是否在 ±60 度内
放周边视野:只做提示
放边缘:仅氛围或移除
按视距折算尺寸
校验:可达锥内是否放得下高频操作
下移层级或改布局
落位
举个例子:文旅展馆的展品信息牌
展馆里用户会在展品之间走动,信息牌如果贴得太高、太偏,就得仰头侧身。按上面的原则排一版:
- 主标题(展品名):放在视线中心偏下一点,水平居中,视角压在 ±8° 内,视距 1.0m 左右,用户站定就能读。
- 说明正文:主标题下方,向下的方向多留空间(下视野比上视野宽),字号按 0.5° 视角设,保证 0.6~1.5m 都能读。
- 操作按钮:"语音讲解""加入行程"放在可达锥内,视距 0.6m,尺寸放大到 56vp 以上,减少空中点选的误差。
- 引导提示:放在 ±40° 的周边位置,用箭头提示"旁边还有内容",不要求用户立刻读。
这套排布的核心是把"要读的、要点的、只要知道的"分到不同的角度和距离带上,用户的动作量自然就降下来了。
总结一下下
- 先定视角,再定尺寸。 视角决定可读性,尺寸只是手段。用视角恒定原则,远近内容才能视觉一致。
- 可达区和高频操作绑死。 返回、确认、切换这类高频动作,位置固定在可达锥里,别放边缘。
- 垂直方向更吝啬。 向上看的舒适角度小,顶部内容要矮一点、少一点。
- 深度方向多留容错。 手在 z 方向的抖动最大,3D 目标在这个方向要比看上去更宽容。
- 用平面思维铺满整个视野。 觉得"反正空间大",把内容摆到 ±80°,用户根本读不了,还会因为频繁转头产生不适。
- 尺寸一刀切。 远近内容用同样大小,远的看着小、近的看着挤,视角不一致,读感很乱。
- 高频操作放在远处。 每次都要大臂移动或转头,几分钟就累,用户会放弃使用。
- 深度方向不留余量。 空中点选在前后方向上误差最大,目标按 2D 标准设大小,误触率会偏高。
- 忽略个体差异。 视野、臂长、颈肩活动度因人而异,参数要给区间,关键操作留备选入口(比如语音或遥控器)。
- 一次性摆满。 内容随场景变化时,动态重排会让用户失去空间记忆,固定区域的职责要稳定。