【共创稿事节】HarmonyOS 7空间化 UX 规范研究:空间设计语言的基本要素
空间化 UX 规范研究:空间设计语言的基本要素
2D 时代我们有一套成熟的 UX 规范:间距系统、字号阶梯、颜色语义、点击热区。这些规范的作用是把"凭感觉"变成"有依据"。空间计算同样需要一套这样的规范,只不过它要约束的维度更多——多出来的那些维度,光靠设计师的直觉是管不住的。
这篇把空间化 UX 拆成六个要素,逐条讲清楚它们分别规范什么、和平面规范怎么对应,最后给出一份可以直接贴进项目文档的检查清单和落地的 token 代码。
六个要素,各自管什么
深度 Depth
平面 UX 用间距和分组表达"这些是一伙的";空间 UX 用深度表达"这些的优先级不同"。
深度的规范要点有三条:层级数量要有上限(一般 3~4 层,再多用户分辨不出)、层间距离要有节奏(比如按 40vp / 100vp / 200vp 分段,而不是随手给个 z)、近层必有阴影或高光(否则人眼无法建立深度线索)。
深度不是越大越好。超过一定距离,透视缩放会让元素小到看不清,反而浪费了屏幕空间。
尺度 Scale
平面的尺度是绝对的(这个按钮 44vp 高)。空间的尺度必须是相对的:同一个组件在近处、中景、远处的屏幕投影尺寸不同,但它的设计尺寸应保持一致,差异交给透视。
规范上要明确:每个层级定义"参考距离"和"标称缩放",元素超出该层级的缩放范围就触发了本不该有的语义变化(比如"它变大了说明它被选中了")。
视角 Perspective
平面不存在视角,固定的正交投影。空间里视角会变,规范要回答的是:哪些变化是允许的、哪些是禁止的。
可接受的做法是绕 Y 轴小幅旋转(±10~15 度)营造立体感;不可接受的是持续的大角度倾斜或频繁的视角抖动,那会让用户眩晕。perspective 值也要统一,它是"视距",越小透视越夸张。
光照 Lighting
光照在空间 UX 里承担两个职责:一是制造深度线索(近亮远暗、高光位置暗示光源方向),二是传递情绪(暖光亲近、冷光理性、暗场聚焦)。
规范要定的是:主光源方向统一(同一页面不能一半亮在左上、一半亮在右下)、层级与明度挂钩(越靠前的层整体越亮)、高光克制(高光过强会让内容读不清)。
声音 Sound
声音是空间 UX 里最容易被忽视、也最有辨识度的通道。空间音频(AudioKit 的 AudioSpatializationManager)可以把声源放到具体的方位上,这给反馈设计开了新路子:左侧的提示音从左边来,前方的主任务音从正前方来。
规范要点:方位要对应元素位置、次要事件不要发声(否则听觉通道过载)、提供静音开关(无障碍与公共场合必须考虑)。
交互词汇 Interaction Vocabulary
平面的交互词汇是:点击、长按、滑动、拖拽。空间的词汇在它们基础上扩展成三层:
| 层级 | 交互 | 语义 | 对应平面概念 |
|---|---|---|---|
| 瞄准 | 视线停留 | “我要选它” | hover |
| 确认 | 手势(捏合、点按) | “就它了” | click |
| 移动 | 头部转动、位移 | “我想换个角度看” | scroll / 翻页 |
这三层必须能组合,也要能撤销。规范里要明确"瞄准多久算选中"“手势失败如何回退”,否则用户会在中途卡住。
和平面 UX 规范的对应关系
| 平面规范 | 空间对应要素 | 差异 |
|---|---|---|
| 间距系统(Spacing) | 深度系统(Depth) | 从二维距离变成三维距离,且与注意力挂钩 |
| 字号阶梯(Type Scale) | 尺度系统(Scale) | 绝对字号变成透视下的相对尺寸 |
| 布局栅格(Grid) | 视角系统(Perspective) | 固定栅格变成可变的观察视角 |
| 颜色语义(Color) | 光照系统(Lighting) | 静态色值变成受光照影响的材质表现 |
| 音效反馈(Sound) | 声音系统(Sound) | 从单声道提示变成带方位的空间音频 |
| 交互状态(States) | 交互词汇(Vocabulary) | 从单层点击扩展到瞄准/确认/移动三层 |
可以看到,平面规范的每一项在空间里都有对应物,但都被"升维"了。这意味着不能直接复用平面规范的数值,但可以复用它的结构——先建系统,再定数值。
把规范写成 token
规范如果只停在文档里,落不了地。可行的做法是把深度、透视、缩放这些要素固化成 ArkTS 常量,组件统一从 token 取值。
// SpatialTokens.ets —— 空间设计 token,全项目共用
export class SpatialLayer {
static readonly ENV = new SpatialLayer('env', -240, 1.0, 0.6);
static readonly CONTEXT = new SpatialLayer('context', -120, 0.85, 0.85);
static readonly FOCUS = new SpatialLayer('focus', 0, 1.0, 1.0);
static readonly OVERLAY = new SpatialLayer('overlay', 120, 1.05, 1.0);
constructor(
public readonly name: string,
public readonly z: number, // 纵深位置(vp)
public readonly nominalScale: number,
public readonly opacity: number // 越远的层整体越暗/越透
) {}
}
// 全局统一的透视视距:全屏同屏元素必须一致,否则纵深不可信
export const SPATIAL_PERSPECTIVE: number = 1000;
// 各层阴影强度:近层阴影更实,远层更虚
export function shadowOf(layer: SpatialLayer) {
const strong = layer.z >= 0;
return {
radius: strong ? 16 : 8,
color: strong ? '#1F000000' : '#0D000000',
offsetY: strong ? 6 : 2
};
}
组件使用时,不再手写魔法数字,而是引用层级:
import { SpatialLayer, SPATIAL_PERSPECTIVE, shadowOf } from './SpatialTokens';
@Component
struct SpatialPanel {
@Prop layer: SpatialLayer = SpatialLayer.FOCUS;
@BuilderParam content: () => void;
build() {
Column() {
this.content()
}
.width('80%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow(shadowOf(this.layer))
// 统一从 token 取纵深、缩放、不透明度;透视值全局一致
.translate({ x: 0, y: 0, z: this.layer.z })
.scale({ x: this.layer.nominalScale, y: this.layer.nominalScale })
.opacity(this.layer.opacity)
.rotate({ x: 0, y: 1, angle: 0, perspective: SPATIAL_PERSPECTIVE })
}
}
这样做有三个好处:深度、透视、阴影的数值只有一处定义;改规范时不用满项目找魔法数字;新加入的开发者照着 token 走就不会跑偏。
空间设计检查清单
下面这份表可以直接复制进项目的设计评审文档,逐项打勾。
| 类别 | 检查项 | 通过标准 |
|---|---|---|
| 深度 | 层级数量 | 不超过 4 层,且每层职责清晰 |
| 深度 | 层间距离 | 间距有节奏(如 40/100/200vp),非随意值 |
| 深度 | 深度线索 | 近层有阴影或高光,远层有降饱和/降透 |
| 尺度 | 尺寸一致性 | 同类组件设计尺寸一致,缩放交给透视 |
| 尺度 | 缩放语义 | 缩放变化有明确含义(如选中放大),不滥用 |
| 视角 | 旋转角度 | 常用旋转控制在 ±15 度内,避免长时间倾斜 |
| 视角 | 透视统一 | 同屏 perspective 使用同一 token 值 |
| 光照 | 光源方向 | 全页主光源方向一致 |
| 光照 | 明度层级 | 越靠前越亮,前景内容对背景有足够对比 |
| 声音 | 方位对应 | 声源方位与元素屏幕位置一致 |
| 声音 | 静音支持 | 提供静音开关,关键信息不依赖声音传递 |
| 交互 | 瞄准反馈 | 视线/悬停有明确选中态 |
| 交互 | 可撤销 | 任何确认动作都能回退,且回退路径清晰 |
| 交互 | 容错 | 手势识别失败有提示,不会静默失败 |
要素协同
这六个要素不是并列的,它们协同起来才构成完整体验。
深度和视角是主变量,光照、尺度、声音、交互都围绕它们展开。规范落地时,先定深度系统和视角系统,其余的跟着定。
案例:把六个要素放进一个空间化音乐播放页
挑一个空间化音乐播放页做推演。选它是因为这个页面同时需要氛围、需要一个稳定焦点,还要让人愿意长时间停留,六个要素里最容易被忽略的声音和光照都能在这里找到用武之地。
页面按纵深切四层:环境层放模糊放大的专辑封面,主体层放当前曲目卡片,上下文层放播放列表,叠加层放传输控制。层数和顺序先定下来,后面所有要素都围着它长。
逐项怎么落
深度决定层级结构。四层各占一个 z 档:环境层 -240、上下文层 -120、主体层 0、叠加层 120,档距固定 120vp,和卡片数量无关。档距固定是为了让用户在不同页面之间迁移时,对"远近"的判断保持一致。
尺度负责补充距离感。主体层按 1.0 设计,上下文列表按 0.85,叠加控制条按 1.05,环境层按 1.6 铺满。这里的 1.6 不是"更大所以更重要",而是氛围层需要超出屏幕才有包裹感——尺度的语义要看层级身份,不能一概而论。所有组件自身的宽高恒定,缩放统一交给 scale。
视角分两类动作。一类是主体卡片在切歌时小幅上倾再回正,角度压在 6 度以内;另一类是唱片匀速自转。自转走的是 z 轴,属于"正在播放"的状态提示,如果走 y 轴就会和纵深感混在一起,让人分不清是卡片在动还是视角在动。
光照固定主光源在左上。封面用 135 度线性渐变模拟高光,环境层整体压到 0.5 不透明度并降低饱和,保证主体有足够对比度。切歌时叠一层短暂暖色提亮,那是情绪提示,不带方向性,别和主光混着用。
声音只在切歌和播放/暂停时发声。切歌提示音从目标卡片所在方位发出,左侧列表来的曲目声音就偏左;播放/暂停用一个短促中性音,不参与方位映射。设备不支持空间音频渲染时退回普通立体声,方位感由卡片的前移与放大补齐,不让声音成为唯一线索。
交互词汇拆成三步。悬停/视线停留进入瞄准态,列表项前移 12vp 但不放大;点击或手势确认后才放大并切歌;横向滑动负责在曲目间移动。瞄准和确认分开的意义在于,滑动浏览时手滑不会顺手把歌切了。
关键实现
import { curves } from '@kit.ArkUI';
// 六要素的数值只在这里定义一次,与文中 token 结构一致
const PERSPECTIVE: number = 1000; // 视角:全屏统一视距,避免纵深失真
class Layer {
static readonly ENV = new Layer(-240, 1.6, 0.5); // 深度:最远,作氛围
static readonly CONTEXT = new Layer(-120, 0.85, 0.85); // 上下文
static readonly FOCUS = new Layer(0, 1.0, 1.0); // 主体
static readonly OVERLAY = new Layer(120, 1.05, 1.0); // 最近
constructor(public z: number, public scale: number, public opacity: number) {}
}
@Component
struct AlbumCover {
@Prop track: string = '';
@Prop scaleValue: number = 1;
@Prop zValue: number = 0;
@Prop tiltAngle: number = 0; // 绕 x 轴上倾,属于"视角"
@Prop spinAngle: number = 0; // 绕 z 轴自转,属于"状态指示"
@Prop opacityValue: number = 1;
build() {
Column() {
Row()
.width('100%')
.aspectRatio(1)
.borderRadius(16)
.backgroundColor('#2B3345')
// 光照:主光源固定在左上,用 135 度线性渐变模拟高光
.linearGradient({ angle: 135, colors: [['#4A5878', 0.0], ['#2B3345', 0.6]] })
Text(this.track).fontSize(16).fontColor(Color.White).margin({ top: 8 })
}
.width(180)
// 尺度:设计宽高恒定,远近只用 scale 表达
.scale({ x: this.scaleValue, y: this.scaleValue })
// 深度:真实 z 位移,产生近大远小与遮挡
.translate({ x: 0, y: 0, z: this.zValue })
// 视角:上倾与自转共用同一个透视值
.rotate({ x: 1, y: 0, angle: this.tiltAngle, perspective: PERSPECTIVE })
.rotate({ x: 0, y: 0, z: 1, angle: this.spinAngle, perspective: PERSPECTIVE })
.opacity(this.opacityValue)
}
}
@Entry
@Component
struct SpatialMusicPlayer {
@State aimingIndex: number = -1; // 瞄准态:-1 表示无
@State currentIndex: number = 0; // 已确认播放的曲目
@State spin: number = 0; // 唱片自转角度
private tracks: string[] = ['潮汐', '夜航', '回声', '雾中'];
aboutToAppear(): void {
// 视角:唱片匀速自转,作为"正在播放"的状态提示,走 z 轴不产生深度歧义
this.getUIContext()?.animateTo(
{ duration: 8000, iterations: -1, curve: Curve.Linear, playMode: PlayMode.Normal },
() => { this.spin = 360; }
);
}
build() {
Stack({ alignContent: Alignment.Center }) {
// 环境层:模糊放大的封面当氛围,压暗且不接收交互
AlbumCover({ track: this.tracks[this.currentIndex], scaleValue: Layer.ENV.scale,
zValue: Layer.ENV.z, opacityValue: Layer.ENV.opacity })
.hitTestBehavior(HitTestMode.None)
// 上下文层:播放列表,悬停进入"瞄准态",只前移不放大
Column({ space: 8 }) {
ForEach(this.tracks, (item: string, index: number) => {
Text(item)
.fontSize(14)
.fontColor(index === this.aimingIndex ? '#FFFFFF' : '#E6EAF2')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(index === this.aimingIndex ? '#33405A' : Color.Transparent)
.borderRadius(8)
.onHover((isHover: boolean) => {
this.aimingIndex = isHover ? index : -1; // 瞄准 ≠ 确认
})
.onClick(() => this.confirm(index))
}, (item: string) => item)
}
.translate({ x: -120, y: 0, z: Layer.CONTEXT.z + (this.aimingIndex >= 0 ? 12 : 0) })
.rotate({ x: 0, y: 1, angle: 6, perspective: PERSPECTIVE })
.scale({ x: Layer.CONTEXT.scale, y: Layer.CONTEXT.scale })
.opacity(Layer.CONTEXT.opacity)
// 主体层:当前曲目,瞄准时上倾,切歌时回正
AlbumCover({
track: this.tracks[this.currentIndex],
scaleValue: Layer.FOCUS.scale,
zValue: Layer.FOCUS.z,
tiltAngle: this.aimingIndex >= 0 ? -6 : 0,
spinAngle: this.spin
})
// 叠加层:传输控制,离观察者最近
Row({ space: 24 }) {
Text('◀◀').fontSize(18).fontColor(Color.White)
Text('▶').fontSize(22).fontColor(Color.White)
Text('▶▶').fontSize(18).fontColor(Color.White)
}
.translate({ x: 0, y: 130, z: Layer.OVERLAY.z })
.scale({ x: Layer.OVERLAY.scale, y: Layer.OVERLAY.scale })
}
.width('100%')
.height('100%')
// 交互词汇:横向滑动切换曲目,位移不足 60vp 不触发,避免误切
.gesture(
PanGesture({ direction: PanDirection.Horizontal, distance: 20 })
.onActionEnd((event: GestureEvent) => {
const count = this.tracks.length;
if (event.offsetX < -60) {
this.confirm((this.currentIndex + 1) % count);
} else if (event.offsetX > 60) {
this.confirm((this.currentIndex - 1 + count) % count);
}
})
)
}
private confirm(index: number): void {
// 确认动作单独一步:放大并复位瞄准态
this.getUIContext()?.animateTo(
{ duration: 250, curve: curves.springMotion(0.9, 18) },
() => {
this.currentIndex = index;
this.aimingIndex = -1;
}
);
}
}
要素与参数对照
| 要素 | 在这个页面上的落点 | 具体参数 |
|---|---|---|
| 深度 Depth | 氛围 / 列表 / 当前曲目 / 控制条四层 | z = -240 / -120 / 0 / 120 vp,档距固定 |
| 尺度 Scale | 组件设计尺寸恒定,远近由 scale 表达 | 0.85 / 1.0 / 1.05 / 1.6 |
| 视角 Perspective | 主体上倾 + 唱片绕 z 自转 | 上倾 ≤ 6°;自转走 z 轴;perspective 全屏统一 1000 |
| 光照 Lighting | 主光源左上,氛围层压暗 | 渐变 angle 135°;氛围层 opacity 0.5 |
| 声音 Sound | 切歌提示音按目标方位发出 | 方位偏差 < 15°;无空间音频能力时退回立体声 |
| 交互词汇 Vocabulary | 瞄准(悬停)/ 确认(点击)/ 移动(滑动) | 瞄准前移 12vp;确认放大;滑动阈值 60vp |
一次切歌走完六个要素的链路是这样的:
这套参数不是唯一的,但它把六个要素都钉在了具体数值上。评审时逐项对着表格问"这个值为什么是它",比对着感觉争论有用得多。
几条小经验
- 规范先定结构,再填数值。层级比具体 z 值重要得多。
- 深度层级宁少勿多。三层做得扎实,胜过六层让人眼花。
- 声音要惜用。它是增强项,不是必需项,关键信息永远要有视觉兜底。
- 把规范写成 token。文档会过时,代码里的常量不会。
- 检查清单要在设计评审时逐条过,别等开发做完再补。
容易出问题的地方
- 同屏多个透视值。这是最常见的空间"穿帮",纵深关系会瞬间失真。
- 用亮度代替深度。远处变暗是辅助线索,不是唯一线索,纯靠明暗会让用户误判。
- 声音方位和元素方位不一致。听觉和视觉打架时,人会更晕。
- 交互词汇混用。把"瞄准"和"确认"合并成一步,会导致误操作,尤其在视线交互里。
- 清单只走形式。评审时打勾但没实际验证,问题会全部堆到开发阶段。
规范有了,剩下的问题是怎么把它套到已经上线的 2D 应用上。最后一篇会给出一个分阶段的改造路线,不推倒重来。
更多推荐


所有评论(0)