【共创稿事节】HarmonyOS 7空间排布原则:视锥、舒适区与可达性

空间界面里能放东西的地方看似无限,实际上有一圈硬边界:看得清的、够得着的、待着不累的,只有一个有限的区域。把内容丢到视野边缘或者手臂够不着的地方,用户就得转头、伸手、扭腰,体验立刻掉档。这篇讲三件事------视锥怎么限制可见范围、舒适区怎么界定体感、可达性怎么决定交互成本,最后给一份能直接用的排布参数表。

视锥:看得见不等于看得清

人眼不是均匀感光的。视野可以粗略分成三圈:

区域 视角范围(相对视线中心) 能力 适合放什么
中心凹视野 约 ±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 标准设大小,误触率会偏高。
  • 忽略个体差异。 视野、臂长、颈肩活动度因人而异,参数要给区间,关键操作留备选入口(比如语音或遥控器)。
  • 一次性摆满。 内容随场景变化时,动态重排会让用户失去空间记忆,固定区域的职责要稳定。
相关推荐
李游Leo2 小时前
HarmonyOS 7 Core Vision Kit + Image Kit:超分批处理的 PixelMap 预算、失败降级与结果原子替换【鸿蒙心迹】
harmonyos
李游Leo2 小时前
HarmonyOS 7 AbilityAccessCtrl + ArkUI:权限弹窗重入治理与提审证据链【鸿蒙心迹】
华为·harmonyos
李游Leo2 小时前
HarmonyOS 7 Spatial Recon Kit + Preferences:重建会话中断恢复与脏任务回收【鸿蒙心迹】
华为·harmonyos
李游Leo3 小时前
《HarmonyOS 7 ArkGraphics 3D 空间设计开发实战》03:glTF模型加载、实例复用与资源生命周期【鸿蒙心迹】
3d·harmonyos
李游Leo4 小时前
HarmonyOS 7 + ArkTS-HUKS:精准碰一碰载荷的签名校验与重放窗口【鸿蒙心迹】
华为·harmonyos
花花_14 小时前
HarmonyOS 7 状态手记 01|页面状态别乱放
华为·harmonyos
李游Leo5 小时前
HarmonyOS 7 + Hvigor-Localization Kit:多语言资源占位符签名与回退链路预检【鸿蒙心迹】
harmonyos
李游Leo5 小时前
HarmonyOS 7 AVSession + Float Window:闪控窗播放控制权的会话移交与重复指令去重【鸿蒙心迹】
华为·harmonyos
李游Leo6 小时前
HarmonyOS 7 + ArkTS-UDMF:精准碰一碰载荷的协议版本协商与字段降级【鸿蒙心迹】
华为·harmonyos