空间化 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 值
光照光源方向全页主光源方向一致
光照明度层级越靠前越亮,前景内容对背景有足够对比
声音方位对应声源方位与元素屏幕位置一致
声音静音支持提供静音开关,关键信息不依赖声音传递
交互瞄准反馈视线/悬停有明确选中态
交互可撤销任何确认动作都能回退,且回退路径清晰
交互容错手势识别失败有提示,不会静默失败

要素协同

这六个要素不是并列的,它们协同起来才构成完整体验。

决定

决定

影响

强化

强化

映射为

映射为

约束

约束

反馈回

深度 Depth

光照 Lighting

尺度 Scale

视角 Perspective

声音 Sound

交互词汇 Vocabulary

深度和视角是主变量,光照、尺度、声音、交互都围绕它们展开。规范落地时,先定深度系统和视角系统,其余的跟着定。

案例:把六个要素放进一个空间化音乐播放页

挑一个空间化音乐播放页做推演。选它是因为这个页面同时需要氛围、需要一个稳定焦点,还要让人愿意长时间停留,六个要素里最容易被忽略的声音和光照都能在这里找到用武之地。

页面按纵深切四层:环境层放模糊放大的专辑封面,主体层放当前曲目卡片,上下文层放播放列表,叠加层放传输控制。层数和顺序先定下来,后面所有要素都围着它长。

逐项怎么落

深度决定层级结构。四层各占一个 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

一次切歌走完六个要素的链路是这样的:

是

否

悬停/视线瞄准列表项

上下文层前移 12vp 进入瞄准态

手势或点击确认?

主体层上倾 6 度

切换曲目并放大

提示音从目标方位发出

主体层回正, 进入播放

保持瞄准态, 可继续滑动浏览

这套参数不是唯一的,但它把六个要素都钉在了具体数值上。评审时逐项对着表格问"这个值为什么是它",比对着感觉争论有用得多。

几条小经验

  • 规范先定结构,再填数值。层级比具体 z 值重要得多。
  • 深度层级宁少勿多。三层做得扎实,胜过六层让人眼花。
  • 声音要惜用。它是增强项,不是必需项,关键信息永远要有视觉兜底。
  • 把规范写成 token。文档会过时,代码里的常量不会。
  • 检查清单要在设计评审时逐条过,别等开发做完再补。

容易出问题的地方

  • 同屏多个透视值。这是最常见的空间"穿帮",纵深关系会瞬间失真。
  • 用亮度代替深度。远处变暗是辅助线索,不是唯一线索,纯靠明暗会让用户误判。
  • 声音方位和元素方位不一致。听觉和视觉打架时,人会更晕。
  • 交互词汇混用。把"瞄准"和"确认"合并成一步,会导致误操作,尤其在视线交互里。
  • 清单只走形式。评审时打勾但没实际验证,问题会全部堆到开发阶段。

规范有了,剩下的问题是怎么把它套到已经上线的 2D 应用上。最后一篇会给出一个分阶段的改造路线,不推倒重来。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐