2D 界面表达层级靠的是位置和大小:重要的放上面、放大、加粗。空间界面多了一个可用的维度——深度。用户天生会用"远近"理解"重要程度",前景的东西就是此刻该看的,背景的东西是环境。把 Z 轴当成一条层级轴来用,界面会立刻变得有主次,而且不用靠加粗和描边硬撑。

为什么深度能表达层级

深度和层级之间的映射,不是设计师拍脑袋定的,跟人的知觉习惯有关。几个稳定的规律:

  • 近大远小。距离越近,物体在视野里占比越大、细节越清晰,天然更吸引注意。这是透视在替你做视觉权重。
  • 前景清晰、背景虚化。相机的景深效果,人从小看到大,已经把"清晰=焦点、模糊=背景"写进了直觉。
  • 遮挡即优先。前面挡住后面的,被挡的就自动退到次要位置,这是遮挡关系最原始的信息编码。
  • 视差与位移。用户移动时,近处的物体位移大、远处的位移小,深度差会自动强化层次感。

所以 Z 轴并不只是一个坐标,它是一条可以被"读"的语义轴。近 = 当下焦点,中 = 内容主体,远 = 环境上下文。 设计师要做的,是把信息的重要性映射到这条轴上。

Z 轴的三个语义带

给空间界面定三档深度,每档对应一个语义带,比无级地"随便挪"要好管得多:

语义带z 参考值视觉特征承载内容交互
前景 / 焦点+80 ~ +160 vp大、亮、清晰、有阴影主任务、详情、弹层点按、输入
中景 / 内容-40 ~ -80 vp正常尺度、正常亮度列表、卡片、图文滚动、选择
背景 / 环境-160 ~ -260 vp小、暗、虚化、无阴影背景氛围、空间锚点无

参考值的意义是让团队用同一套"刻度"说话,而不是每次拍脑袋。数值本身可以调,但档与档之间要拉开足够差距,z 相差太小,透视压缩后几乎看不出区别。

一个常见误区:以为把元素 translate 到更近的 z 就会变大。在 ArkUI 里,translate 的 z 分量只有在透视生效时才会改变视觉大小,而透视由 rotate 的 perspective 参数控制(API 10 起支持)。没开透视,z 平移就是纯粹的前后遮挡位移。

zIndex 与 z 坐标:别把它们混着用

这两个都带个"z",但管的事完全不同,是空间布局里最容易绕晕的一对概念。

zIndex 管渲染顺序(谁盖住谁)。 它是同一容器内兄弟组件的堆叠顺序,值越大显示层级越高。它不影响透视、不影响大小,纯靠声明顺序和数值决定覆盖。跨容器的组件,zIndex 是无效的。

z 坐标管空间位置(谁离用户近)。 通过 translate({ z })、rotate 的 centerZ、scale 的 z,或者 transform3D 矩阵来改。它决定透视下的尺度和视差,但不管遮挡——一个 z 更近的元素,如果 zIndex 更低,照样可能被盖住。

正确的配合方式是:用 z 坐标定义层次关系,用 zIndex 保证遮挡顺序跟着层次走。 别指望只动一个。

// 用常量把"语义层级"固化下来,避免代码里散落魔数
const enum DepthBand {
  Background = -200, // 环境层
  Content = -60,     // 内容层
  Focus = 120        // 焦点层
}

// zIndex 也按层级给,保证遮挡与深度一致
const enum LayerOrder {
  Background = 0,
  Content = 1,
  Focus = 10
}

前后层切换:用状态驱动深度

空间层级切换的骨架很简单:一个状态表示"当前哪层在前",深度和虚化都由它推导出来。下面是一个可复用的实现。

// DepthStage.ets —— 用 z 坐标 + zIndex 组织三层,并支持前后层切换
@Component
export struct DepthStage {
  // expanded 为 true 时,焦点层前移,其余层后退
  @State expanded: boolean = false
  private readonly perspective: number = 900

  // 统一的深度推导,避免各处硬编码
  private backgroundZ(): number { return this.expanded ? -280 : -200 }
  private contentZ(): number { return this.expanded ? -120 : -60 }
  private focusZ(): number { return this.expanded ? 140 : 0 }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      // 环境层
      Column()
        .width('150%').height('150%')
        .backgroundColor('#0E1220')
        .translate({ z: this.backgroundZ() })
        .rotate({ perspective: this.perspective })
        .blur(this.expanded ? 20 : 4)   // 展开时背景更虚
        .zIndex(0)

      // 内容层
      Column() { this.contentCard() }
        .translate({ z: this.contentZ() })
        .rotate({ perspective: this.perspective })
        .opacity(this.expanded ? 0.55 : 1) // 后退时降透明度,进一步让位
        .zIndex(1)

      // 焦点层:默认收起在内容层附近,展开时前移
      Column() { this.detailPanel() }
        .width(this.expanded ? 360 : 0)
        .translate({ z: this.focusZ() })
        .rotate({ perspective: this.perspective })
        .zIndex(10)
    }
    .width('100%').height('100%')
    .onClick(() => {
      // 一次动画同时改三个深度,层次切换才连贯
      this.getUIContext()?.animateTo(
        { duration: 380, curve: Curve.Friction },
        () => { this.expanded = !this.expanded }
      )
    })
  }

  @Builder
  contentCard() {
    Column({ space: 8 }) {
      Text('云雾乌龙').fontSize(18).fontColor(Color.White)
      Text('¥ 88 / 罐').fontSize(16).fontColor('#FF9AA2')
    }
    .padding(20).borderRadius(16).backgroundColor('#1A2238')
  }

  @Builder
  detailPanel() {
    Column({ space: 12 }) {
      Text('产地与工艺').fontSize(18).fontColor(Color.White)
      Text('轻发酵,炭焙提香,适合 90℃ 水温。')
        .fontSize(14).fontColor('#C7CEDB')
    }
    .padding(24).borderRadius(20).backgroundColor('#2A3350')
  }
}

这段代码里有三处安排是刻意的:

  1. 深度集中推导。 三个 xZ() 函数把语义带和状态解耦,以后调参数只改一处。
  2. 虚化跟着状态走。 背景层 blur 随展开加深,配合前景前移,景深关系才成立。
  3. 一个 animateTo 改多个属性。 层次切换是整体的,分几段动画会显得"各走各的",破坏空间感。

案例:卡片详情展开的空间化

把上面的骨架套到商品卡片的"点开看详情"。2D 里的做法是卡片放大、翻页或者弹出新页面,用户会丢失"我从哪来的"这个上下文。空间化后,处理方式变成:

  • 卡片本体留在中景原位,稍微后退并虚化,它还在那儿,告诉用户"你从这张卡来";
  • 详情面板从卡片位置前移到焦点层,成为当前唯一可读对象;
  • 关闭时,详情退回中景、与卡片合并,视觉上是"收回去",而不是"换了个页面"。
// 关键状态与关闭手势
@State detailVisible: boolean = false

// 展开:面板前移 + 原卡片后退虚化
private openDetail() {
  this.getUIContext()?.animateTo({ duration: 360, curve: Curve.Friction }, () => {
    this.detailVisible = true
  })
}

// 关闭:面板退回,卡片复位
private closeDetail() {
  this.getUIContext()?.animateTo({ duration: 300, curve: Curve.Friction }, () => {
    this.detailVisible = false
  })
}

"点按卡片"

"面板前移 z=140"

"点按空白或返回"

"面板退回 z=0"

Collapsed

Expanding

Focused

Collapsing

卡片后退 z=-120 并 blur 18
仅焦点层接收手势

前后层切换的对照

处理方式常用 API效果适用场景
只改 zIndexzIndex元素平移前后遮挡,无纵深浮层、Toast
改 z 坐标translate({ z }) + perspective有透视缩放和视差层级切换、聚焦
缩放模拟scale近大远小,但无视差轻量聚焦
矩阵变换transform3D完整的 3D 变换复杂 3D 排布

总结一下下

  • 语义先定,数值后调。 先把三档语义带定下来,再让设计稿去贴合,比反向凑数值省事。
  • 焦点层同一时刻只留一个。 两个东西同时"最近",用户不知道该看谁。
  • 虚化要配合后退。 只虚化不后退,看着像图层蒙了层雾;只后退不虚化,背景又会抢戏。
  • 给动画留时间。 深度切换 300~400ms 比较舒服,太快会晕,太慢会拖沓。
  • 中景是"家",别让焦点层把中景遮死。 留出可见的边缘,用户才有返回的参照。

容易出问题的地方

  • 把 zIndex 当深度用。 结果是一堆浮层贴在同一个平面上,越叠越乱,没有空间感。
  • 跨容器用 zIndex。 不同 Stack 容器里的组件,zIndex 无法跨容器改变层级,调了也没反应。
  • 动态改 zIndex 忽略稳定排序。 在不增删兄弟节点时,zIndex 变化会在原层级顺序上稳定排序,结果可能和"值大者在上"的直觉不一致。
  • 细节面板宽度直接从 0 动画到 360。 只改宽度会触发反复重排,改 scale 或 opacity 更顺滑。
  • 焦点层不限制命中区域。 前移之后它的触摸区域也放大了,容易误触到后面本该被挡住的控件,建议焦点态下让背景层 hitTestBehavior 设为 None。
Logo

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

更多推荐