平面界面里,用户的眼睛被屏幕边界框着,注意力顶多在矩形内跳来跳去。空间界面没有这个框,用户能看的地方变多了,注意力反而更容易散。这时候设计的活儿就是主动引导:明确告诉用户"先看这里,再看那里"。焦点和景深是两把最顺手的工具。
焦点怎么建立
让一个元素成为视觉焦点,可用的手段不止放大一种。把它们列全了,才好知道什么时候用哪个:
| 手段 | 具体做法 | 强度 | 副作用 |
|---|---|---|---|
| 尺度 | 放大元素、缩短视距 | 强 | 占视野,挤压周围 |
| 亮度 | 提高brightness,焦点更亮 |
中 | 过亮会刺眼 |
| 对比 | 焦点明、背景暗,或反色 | 强 | 对比过强显生硬 |
| 位置 | 放在视线中心、中轴线上 | 强 | 与舒适区绑定 |
| 颜色 | 焦点用饱和色,背景降饱和 | 中 | 品牌色过多会失效 |
| 运动 | 焦点有微动,背景静止 | 强 | 滥用会干扰阅读 |
| 清晰度 | 焦点实,背景虚 | 强 | 算力开销 |
这些手段单独用都成立,但叠两到三种就够。七种全上,用户看到的是一团互相打架的信号,焦点反而没了。经验值是:尺度 + 清晰度作为主手段,亮度或颜色作为辅助,运动只在需要"抓住"用户时用。
一个常见问题:焦点一堆,等于没有焦点。空间界面里,同一时刻的强焦点建议只留一个,其余降一档。
景深(DoF)为什么能引导注意力
景深原本是光学概念------镜头对焦在某段距离上,那段距离内清晰,前后逐渐模糊。人眼本身也有这个机制,所以"清晰 vs 模糊"天然对应"关注 vs 忽略",不需要额外学习。
在 ArkUI 里没有真正的物理景深相机,但可以用视觉信号模拟,而且效果够用:
- 模糊 :
blur(value)给离焦层加模糊,值越大越虚,单位 px; - 亮度 :
brightness(value)乘数调亮度,值小于 1 变暗(1 为原样); - 饱和度 :
saturate(value)降饱和(1 为原样),让离焦内容退成灰调; - 透明度 :
opacity降透明,让离焦层更"退后"; - 线性渐变模糊 :
linearGradientBlur可以只在某个方向上渐次模糊,适合做"近处清晰、远处渐散"的空间纵深。
组合起来就是一套"离焦权重":
typescript
// 离焦程度 0~1:0 为完全聚焦,1 为完全离焦
@Builder
defocused(level: number) {
Column() {
// 组件内容
}
.blur(level * 14) // 模糊随离焦加深
.brightness(1 - level * 0.35) // 变暗,退到次要位置
.saturate(1 - level * 0.6) // 降饱和,收掉颜色干扰
.opacity(1 - level * 0.25) // 稍微透明
}
这几个值按同一个 level 联动,比各调各的要稳。聚焦态 level = 0,完全一致地回到原样。
视觉权重怎么分配
多层信息同时在场时,给每层定一个"权重预算",避免所有东西都在喊"看我"。下面是一份可直接套用的起始表,权重总和控制在 100% 以内:
| 层级 | 尺度 | 亮度系数 | 饱和度 | 模糊 px | 透明度 | 权重预算 |
|---|---|---|---|---|---|---|
| 焦点 | 1.0 | 1.05 | 1.0 | 0 | 1.0 | 55% |
| 次焦点 | 0.95 | 0.9 | 0.8 | 0~4 | 0.95 | 25% |
| 内容 | 0.9 | 0.8 | 0.7 | 6~10 | 0.9 | 15% |
| 环境 | 0.8 | 0.6 | 0.5 | 14~20 | 0.8 | 5% |
这套值不是硬规定,是"起步价"。真正落地时按场景调,但层级之间要拉开可见的差距,相邻两层差别太小,用户读不出主次。
焦点切换的参数化实现
把上面这张表写成一个可复用的焦点档位,用枚举表达语义,代码里就不会到处散落魔法数字。
typescript
// 焦点档位:越靠前权重越高
enum FocusLevel {
Focus = 0, // 焦点
Secondary = 1, // 次焦点
Content = 2, // 内容
Ambient = 3 // 环境
}
// 每个档位对应一组视觉参数
interface FocusStyle {
blur: number
brightness: number
saturate: number
opacity: number
scale: number
}
function styleOf(level: FocusLevel): FocusStyle {
switch (level) {
case FocusLevel.Focus:
return { blur: 0, brightness: 1.05, saturate: 1.0, opacity: 1.0, scale: 1.0 }
case FocusLevel.Secondary:
return { blur: 3, brightness: 0.9, saturate: 0.8, opacity: 0.95, scale: 0.96 }
case FocusLevel.Content:
return { blur: 8, brightness: 0.78, saturate: 0.68, opacity: 0.9, scale: 0.92 }
default:
return { blur: 16, brightness: 0.6, saturate: 0.5, opacity: 0.8, scale: 0.88 }
}
}
案例:空间化通知中心的聚焦切换
手机通知中心就是把一堆通知平铺排列,用户靠滑动和分组找重点。空间化后,可以让通知卡片围成一圈或几排,用户把视线落在哪张卡上,那张卡就自动进入焦点档,其余按距离退到次焦点、内容、环境档。
typescript
// NotificationOrbit.ets ------ 通知卡片环形排布,聚焦项自动升权
@Entry
@Component
struct NotificationOrbit {
// 当前聚焦的卡片索引,-1 表示无聚焦
@State focusIndex: number = -1
// 示例通知数据
private notices: string[] = ['日程提醒', '系统更新', '消息回复', '待办临近', '快递到达', '账单提醒']
// 根据与聚焦项的距离推导档位:越远越离焦
private levelFor(index: number): FocusLevel {
if (this.focusIndex < 0) return FocusLevel.Content // 未聚焦时全部等权
const dist = Math.abs(index - this.focusIndex)
if (dist === 0) return FocusLevel.Focus
if (dist === 1) return FocusLevel.Secondary
if (dist === 2) return FocusLevel.Content
return FocusLevel.Ambient
}
build() {
Stack({ alignContent: Alignment.Center }) {
ForEach(this.notices, (text: string, index: number) => {
Column() {
Text(text).fontSize(16).fontColor(Color.White)
}
.width(160).height(96)
.borderRadius(16)
.backgroundColor('#1E2740')
// 用角度把卡片摆成一圈,转头的动作对应切换聚焦
.rotate({ angle: index * 30, centerX: '50%', centerY: '700%', perspective: 1200 })
.applyFocus(this.levelFor(index)) // 自定义扩展:套用焦点档位
.onHover((isHover: boolean) => {
// 视线/指针落在哪张卡上,哪张卡成为焦点
this.getUIContext()?.animateTo({ duration: 220, curve: Curve.Friction }, () => {
this.focusIndex = isHover ? index : -1
})
})
}, (text: string) => text)
}
.width('100%').height('100%')
.backgroundColor('#0B1020')
}
}
// 通过 AttributeModifier / 自定义修饰器把 FocusStyle 落到组件上
@Extend(Column)
function applyFocus(level: FocusLevel) {
.blur(styleOf(level).blur)
.brightness(styleOf(level).brightness)
.saturate(styleOf(level).saturate)
.opacity(styleOf(level).opacity)
.scale({ x: styleOf(level).scale, y: styleOf(level).scale })
}
这套逻辑的关键是档位由"距离"推导 ,而不是每张卡单独设状态。聚焦项一动,整排的权重自动重排,不用维护一堆 if。onHover 在这里只是示意入口,实际项目里换成 onFocus、视线追踪回调或者指针位置都可以。
#mermaid-svg-q0X2n9BdUT5umv9r{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-q0X2n9BdUT5umv9r .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q0X2n9BdUT5umv9r .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q0X2n9BdUT5umv9r .error-icon{fill:#552222;}#mermaid-svg-q0X2n9BdUT5umv9r .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q0X2n9BdUT5umv9r .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q0X2n9BdUT5umv9r .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q0X2n9BdUT5umv9r .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q0X2n9BdUT5umv9r .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q0X2n9BdUT5umv9r .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q0X2n9BdUT5umv9r .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q0X2n9BdUT5umv9r .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q0X2n9BdUT5umv9r .marker.cross{stroke:#333333;}#mermaid-svg-q0X2n9BdUT5umv9r svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q0X2n9BdUT5umv9r p{margin:0;}#mermaid-svg-q0X2n9BdUT5umv9r .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-q0X2n9BdUT5umv9r .cluster-label text{fill:#333;}#mermaid-svg-q0X2n9BdUT5umv9r .cluster-label span{color:#333;}#mermaid-svg-q0X2n9BdUT5umv9r .cluster-label span p{background-color:transparent;}#mermaid-svg-q0X2n9BdUT5umv9r .label text,#mermaid-svg-q0X2n9BdUT5umv9r span{fill:#333;color:#333;}#mermaid-svg-q0X2n9BdUT5umv9r .node rect,#mermaid-svg-q0X2n9BdUT5umv9r .node circle,#mermaid-svg-q0X2n9BdUT5umv9r .node ellipse,#mermaid-svg-q0X2n9BdUT5umv9r .node polygon,#mermaid-svg-q0X2n9BdUT5umv9r .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-q0X2n9BdUT5umv9r .rough-node .label text,#mermaid-svg-q0X2n9BdUT5umv9r .node .label text,#mermaid-svg-q0X2n9BdUT5umv9r .image-shape .label,#mermaid-svg-q0X2n9BdUT5umv9r .icon-shape .label{text-anchor:middle;}#mermaid-svg-q0X2n9BdUT5umv9r .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-q0X2n9BdUT5umv9r .rough-node .label,#mermaid-svg-q0X2n9BdUT5umv9r .node .label,#mermaid-svg-q0X2n9BdUT5umv9r .image-shape .label,#mermaid-svg-q0X2n9BdUT5umv9r .icon-shape .label{text-align:center;}#mermaid-svg-q0X2n9BdUT5umv9r .node.clickable{cursor:pointer;}#mermaid-svg-q0X2n9BdUT5umv9r .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-q0X2n9BdUT5umv9r .arrowheadPath{fill:#333333;}#mermaid-svg-q0X2n9BdUT5umv9r .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-q0X2n9BdUT5umv9r .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-q0X2n9BdUT5umv9r .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q0X2n9BdUT5umv9r .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-q0X2n9BdUT5umv9r .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q0X2n9BdUT5umv9r .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-q0X2n9BdUT5umv9r .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-q0X2n9BdUT5umv9r .cluster text{fill:#333;}#mermaid-svg-q0X2n9BdUT5umv9r .cluster span{color:#333;}#mermaid-svg-q0X2n9BdUT5umv9r 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-q0X2n9BdUT5umv9r .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-q0X2n9BdUT5umv9r rect.text{fill:none;stroke-width:0;}#mermaid-svg-q0X2n9BdUT5umv9r .icon-shape,#mermaid-svg-q0X2n9BdUT5umv9r .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q0X2n9BdUT5umv9r .icon-shape p,#mermaid-svg-q0X2n9BdUT5umv9r .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-q0X2n9BdUT5umv9r .icon-shape .label rect,#mermaid-svg-q0X2n9BdUT5umv9r .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q0X2n9BdUT5umv9r .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-q0X2n9BdUT5umv9r .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-q0X2n9BdUT5umv9r :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中卡片 i
用户视线/指针移动
落在哪张卡片
focusIndex = i
按距离推导档位
i 本身:Focus 档
i±1:Secondary 档
更远:Content / Ambient 档
前移 + 去模糊 + 提亮
轻微后退 + 微模糊
后退 + 强模糊 + 降饱和
聚焦与离焦的对照
| 维度 | 聚焦(Focus) | 离焦(Defocus) |
|---|---|---|
| 距离感 | 近,前移 | 远,后退 |
| 清晰度 | 实,blur=0 | 虚,blur 随距离递增 |
| 亮度 | 略提亮 | 压暗 |
| 颜色 | 保持饱和 | 降饱和 |
| 动效 | 前移、轻微呼吸 | 静止、退让 |
| 交互 | 接收输入 | 关闭命中 |
小小经验
- 同一时刻一个强焦点。 两个同等强度的焦点会互相削弱,用户的视线会在两者间来回跳。
- 模糊是"降权",不是装饰。 如果只是想加个好看的虚化,别用 blur,它会被用户读成"这里不用看"。
- 聚焦切换要快。 200~250ms 左右,比深度层级切换(300~400ms)更快,因为它是高频动作。
- 给离焦内容留"回来的路"。 离焦不等于消失,保留轮廓和轻度可读性,用户才知道可以移回去。
- 亮度优先于模糊做降权。 模糊开销大,能用亮度和饱和度区分的场合,别急着上 blur。
容易出问题的地方哦
- 所有元素一起提亮做焦点。 提亮是全屏相对概念,全都亮等于全都没亮,得留暗部做对比。
- blur 值给太大。 超过 20px 的内容基本认不出,用户会以为加载失败,而不是"这里次要"。
- 聚焦态还保持可点。 离焦的卡片如果还能点,用户误触后会觉得"我没看它怎么触发了",建议离焦层关闭或降低命中优先级。
- 只改视觉不改顺序。 焦点在视觉上是前面的,但如果
zIndex没跟着调,它可能被后面的卡片盖住。 - 动画里同时改太多属性。 blur、brightness、scale 一起重算,低端设备容易掉帧,建议聚焦切换时限制在 2~3 个属性同时变化。
- 忽略个体差异。 有的人对运动敏感,大范围的聚焦动画可能引起不适,建议给系统级的"减少动效"开关留一个退化路径。