写鸿蒙界面做得久了,你会发现一个很有意思的现象:同样是给卡片加阴影,有的开发者一行 .shadow() 就完事,出来的效果像贴了张灰纸片;有的开发者却能让组件真正“浮”在屏幕上,层次分明、过渡柔和。HarmonyOS 应用实例做到第 178 篇,这次聊的就是影子与投影模拟,而且我打算先把一个容易被忽略的事实讲清楚——.shadow() 不等于投影。真正让卡片“飘起来”的,往往是模糊、渐变、透明度、多层叠影组合出来的视觉结果。
这篇文章不是 API 文档搬运,我会把 shadow、boxShadow、blur 模拟投影、渐变造影这几种方式拆开讲,配合可直接复制的代码和参数表,说清楚各自的适用边界,也会把阴影被裁剪、列表掉帧、动画抖动这些我在实际项目里踩过的问题一并翻出来。适合刚接触 ArkUI 的开发者,也适合做组件库时和阴影死磕的初级工程师。
1. 为什么“卡片投影”不等于一行 shadow 属性
1.1 shadow 到底在画什么
ArkUI 的 .shadow() 做的事情并不复杂:它先把组件最终的形状渲染到一张离屏纹理上,对这张纹理做高斯模糊,再按照偏移量画到组件背后。你可以把它理解成 CSS box-shadow 的简化版,但它有几个特性需要注意。
第一,阴影是沿组件的不透明区域轮廓生成的,并不是只能画在矩形四边。组件有圆角、有背景色、有自绘内容时,阴影形状都会跟着变化。第二,阴影是在组件外部扩展绘制的,所以父容器一旦开启裁剪,阴影就会少一块。第三,它的模糊是均匀模糊,不会区分“靠近接触面的地方实、远处虚”。
看一段最基础的代码:
typescript复制Column()
.width(200)
.height(200)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({
radius: 20,
color: 'rgba(15, 23, 42, 0.10)',
offsetX: 0,
offsetY: 8
})
这段代码生成的效果,就是在卡片下方 8vp 的位置多出一块半径为 20 的半透明模糊区域。想让阴影更实,要加的是 color 的 alpha;想让阴影更柔和,要加 radius,但通常还得同步降低 alpha,否则阴影会发黑发脏。
1.2 真实投影的物理规律与 UI 表现的差异
真实世界里,影子是光源、遮挡物、支撑面三者共同作用的结果。光源离物体越近,影子越实、边缘越清晰;光源越远,影子越虚、边缘越散,整体颜色也越淡。落到手机 UI 里,设计师其实大量参考了 Material Design 的“海拔”概念,把投影拆成了两层:
一层是接触阴影(contact shadow),负责表现卡片紧贴底部的位置关系,短、实、近;另一层是环境阴影(ambient shadow),负责表现卡片整体对环境光的遮挡,宽、虚、远。设计稿里那些看起来柔和且高级的阴影,几乎都是这两层叠加出来的。而 .shadow() 一次只能画一层均匀模糊,所以期望用一行属性还原整个设计稿,本身就是不现实的。
1.3 设计稿里的阴影经常不是一个属性
用 Figma 或 Sketch 的开发者应该都有印象,设计稿里一种阴影效果可能包含 X/Y 位移、Blur、Spread、颜色透明度这么多参数,有些卡片甚至会叠三四层。ArkUI 里对应的能力并不全在 shadow 上。
| 设计稿参数 | ArkUI 对应 | 说明 |
|---|---|---|
| X / Y 位移 | offsetX / offsetY | Y 为正表示阴影向下 |
| Blur 模糊 | radius | 数值越大越柔和 |
| Spread 扩展 | boxShadow 的 spread | shadow 没有扩展能力 |
| 颜色 + 透明度 | color 中的 rgba | 透明度写在 color 里 |
| 多层阴影 | 分层组件叠加 | 单属性不支持多阴影列表 |
理解了这一层,再去看各种“为什么阴影不像设计稿”的问题,思路就会清晰很多:不是参数填得不对,而是方案本身选得不对。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. shadow 与 boxShadow:差异、用法和适用边界
2.1 shadow 的轻量用法与预置 ShadowStyle
shadow 适合日常 80% 的场景。它的参数非常少,只有 radius、color、offsetX、offsetY,心智负担低,也容易调。绝大多数默认状态下的卡片悬浮效果,用这一个属性就够了。
除了自定义选项,ArkUI 还提供了一批预置阴影样式,例如:
typescript复制.shadow(ShadowStyle.OUTER_DEFAULT_MD)
ShadowStyle 枚举里有从 XS 到 LG 几档预设值,适合快速搭原型。但它的颜色、偏移和模糊半径已经固定,没法跟着设计稿微调,所以正式版本我不太推荐用。自己写 ShadowOptions 其实也就多几行代码,灵活性完全不一样。
2.2 boxShadow 补上 spread 和 inset 两个缺口
如果你发现阴影需要“向内收缩”或“向内投影”,shadow 就无能为力了,这时要用 boxShadow。它多了 spread 和 inset 两个关键参数。
typescript复制Column()
.width(200)
.height(200)
.backgroundColor(Color.White)
.borderRadius(16)
.boxShadow({
radius: 20,
color: 'rgba(15, 23, 42, 0.12)',
offsetX: 0,
offsetY: 8,
spread: -6,
inset: false
})
spread 为正数时,阴影会向外扩展;为负数时,阴影会向内收缩。很多设计稿里那种“贴边细阴影”其实都是靠负 spread 做出来的,而不是单纯调小 radius。inset 设为 true 时,阴影画在组件内部,适合做按压凹陷、磨砂内凹的视觉效果。
我自己的习惯是:只做外阴影用 shadow,需要控制阴影扩散范围或做内阴影时再用 boxShadow。两者不要混用,因为同时存在时会增加渲染负担,而且视觉上容易打架。
2.3 Canvas 任意形状的自绘阴影
UI 组件自带的阴影只能跟随组件的矩形圆角轮廓,如果你需要给不规则路径、手绘形状、甚至一段文字加阴影,就得用 Canvas 的自绘能力。
typescript复制private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
Canvas(this.ctx)
.width(200)
.height(200)
.onReady(() => {
this.ctx.shadowColor = 'rgba(0, 0, 0, 0.18)';
this.ctx.shadowBlur = 20;
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 8;
this.ctx.fillStyle = '#FFFFFF';
this.ctx.beginPath();
this.ctx.rect(20, 20, 160, 160);
this.ctx.fill();
})
Canvas 的 shadow 系列属性和 Web Canvas 基本一致,设置好 shadowBlur、shadowColor 和偏移量,再绘制任意路径,阴影就会自动生成。这种方式的优势是完全不受组件轮廓限制,代价是绘制逻辑更重,而且阴影参数不会自动参与 ArkUI 的状态动画,需要自己在 onReady 里管理重绘。
3. 模拟真实投影的几种“非标准”手段
3.1 模糊椭圆假投影:接触阴影的经典做法
标准的 shadow 是沿组件轮廓均匀模糊的,但真实世界里,一个悬浮物体的接触阴影往往更接近椭圆形态,而不是一个被放大的圆角矩形。为了模拟这种形态,我常用的一个手段是:在卡片下方放一个椭圆,给椭圆设置透明度,再用 blur 把它模糊掉。
typescript复制Stack({ alignContent: Alignment.Bottom }) {
Ellipse()
.width(240)
.height(48)
.fill('#000000')
.opacity(0.16)
.blur(16)
.translate({ y: 12 })
Column() {
Text('悬浮卡片')
.fontSize(16)
.fontWeight(FontWeight.Medium)
}
.width(200)
.height(160)
.backgroundColor(Color.White)
.borderRadius(16)
}
.width(280)
.height(220)
.justifyContent(FlexAlign.End)
这段代码的视觉逻辑是:椭圆正好落在卡片底部正中,被模糊后边缘化开,形成一片“接地感”很强的阴影。卡片和椭圆之间没有硬边,看起来比单纯圆角矩形阴影自然很多。这个技巧在组件尺寸固定时特别实用,比如首页金刚区图标、个人中心头像卡位、商品卡片。
3.2 渐变造影法:省性能的单侧阴影
有些场景根本不需要模糊,比如列表底部渐隐、抽屉底部阴影、图片底部压暗。这时候用线性渐变已经能模拟出“下方有物体遮挡”的投影效果,而且完全不产生模糊计算,性能开销几乎为零。
typescript复制Column()
.width(300)
.height(80)
.linearGradient({
angle: 180,
colors: [
['rgba(0, 0, 0, 0.20)', 0.0],
['rgba(0, 0, 0, 0.00)', 0.8]
]
})
这个渐变从顶部透明到接近黑色,视觉上和“底部阴影”很接近。把它放在列表底部蒙层、弹窗底部背景、图片底部文字遮罩上,观感比纯色好很多,而且不会像 shadow 那样产生额外的离屏纹理。需要注意角度方向,angle 为 0 时从右往左渐变,180 时从上往下渐变,想投影在底部,就用从上往下的透明度变化。
3.3 多层阴影叠加:还原设计稿的“悬浮感”
前面说过,设计稿里的高级阴影通常由接触阴影和环境阴影两层组成。ArkUI 不支持一条属性里写多个阴影,但可以用 Stack 叠出两层来。
typescript复制Stack({ alignContent: Alignment.Center }) {
Column()
.width(200)
.height(200)
.borderRadius(16)
.backgroundColor(Color.White)
.shadow({
radius: 32,
color: 'rgba(0, 0, 0, 0.08)',
offsetY: 16
})
Column() {
Text('内容区域')
}
.width(200)
.height(200)
.borderRadius(16)
.backgroundColor(Color.White)
.shadow({
radius: 8,
color: 'rgba(0, 0, 0, 0.15)',
offsetY: 4
})
}
底层负责环境光,模糊半径大、透明度低、偏移大;上层负责接触阴影,模糊半径小、透明度稍高、偏移小。两张白色卡片叠在一起,视觉上就是一层完整的、有层次感的投影。这里有个细节:两层卡片必须保持完全相同的尺寸和圆角,否则叠在一起会出现白边错位。圆角变化时,两层要同步修改。
4. 三个高频场景的参数模板与状态切换
4.1 卡片悬浮:不同海拔下的 shadow 参数
做卡片组件时,我习惯定义一个 elevation 概念,普通状态、悬停状态、拖拽状态分别对应不同的海拔,再根据海拔映射 shadow 参数。
| 状态 | radius | color alpha | offsetY | 适用场景 |
|---|---|---|---|---|
| 默认 | 16 | 0.08 | 4 | 页面上普通静态卡片 |
| hover | 24 | 0.12 | 8 | 鼠标悬停、遥控器聚焦 |
| 拖起 / 选中 | 32 | 0.16 | 16 | 拖拽中、弹窗、浮层 |
这里的颜色建议统一用深色配合低透明度,例如 rgba(15, 23, 42, alpha),比纯黑更干净,不会发灰。alpha 和 offset 是一组联动的值,offset 越大 alpha 就适当调大,否则大偏移下的阴影淡到看不见,悬浮感就没了。
4.2 按钮按压:阴影跟随手指反馈
按钮按压时,最自然的反馈是“按钮被按下去”,也就是阴影变短、变实,同时按钮背景略微变深。用状态变量控制 shadow 参数,再配一个 150~200ms 的动画,手感会很好。
typescript复制@State pressed: boolean = false;
Column() {
Text('点击我')
}
.width(160)
.height(48)
.backgroundColor(this.pressed ? '#F1F5F9' : '#FFFFFF')
.borderRadius(8)
.shadow({
radius: this.pressed ? 8 : 24,
color: this.pressed ? 'rgba(15, 23, 42, 0.06)' : 'rgba(15, 23, 42, 0.14)',
offsetY: this.pressed ? 2 : 8
})
.animation({ duration: 180, curve: Curve.EaseOut })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.pressed = true;
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.pressed = false;
}
})
注意这里的动画时长不要拖太长。阴影变化超过 250ms 就会有“迟滞感”,破坏按压的即时反馈。另一点是 offsetY 从 8 变到 2,卡片本身位置不建议也跟着往下移动 2vp,那样反而会让按钮“跳一下”,只动阴影就够了。
4.3 底部导航栏:阴影方向与性能取舍
底部导航栏的投影一般应该朝上,也就是 offsetY 为负值。
typescript复制Row() {
// 导航项
}
.width('100%')
.height(56)
.backgroundColor(Color.White)
.shadow({
radius: 16,
color: 'rgba(0, 0, 0, 0.08)',
offsetX: 0,
offsetY: -8
})
但如果页面本身有滚动内容,阴影会跟着导航栏一起滚动,视觉上很怪。实际项目里更常见的做法是:导航栏不挂阴影,而是在页面滚动时动态给导航栏加一层背景模糊或顶部细线分隔。这样既保留了层次感,又避免了全屏滚动时阴影区域频繁重绘,对帧率的影响小得多。
5. 阴影不显示、被裁剪、掉帧的排查实录
5.1 “阴影没有出来”的五个实际原因
我在社区和实际项目里见过大量“为什么阴影没显示”的问题,多数不是 bug,而是下面几个原因:
- 组件没有背景色或背景完全透明,阴影沿透明区域绘制,视觉上几乎不可见。先给组件加 background 颜色再看效果。
- 父容器开了裁剪。Scroll、List、Tabs 等滚动容器在滚动方向很可能裁剪溢出内容,阴影如果画在可视区域外,会被直接切掉。
- 组件的尺寸等于父容器尺寸,阴影画在组件外,父容器又没有预留空间,等于画到了屏幕外。
- 阴影颜色透明度太低,在深色背景上肉眼根本分辨不出来。调试时先用
rgba(0, 0, 0, 0.5)这种高对比度颜色确认是否生效,再调回目标值。 - 阴影被其它组件遮挡。Stack 后面的组件会盖住前面组件的阴影,这不是阴影没生效,而是层级问题。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 阴影完全不可见 | 背景透明 / 无背景色 | 设置 opacity 大于 0 的背景色 |
| 阴影缺一边或没有 | 父容器裁剪 | 给父容器加 padding 或关闭裁剪 |
| 阴影被遮挡 | 组件层级不对 | 调整 Stack 顺序或 zIndex |
| 阴影过黑过脏 | radius 大但 alpha 也大 | 调大 radius 同时降低 alpha |
| 真机与模拟器不一致 | 屏幕色彩模式差异 | 用标准色彩模式对比 |
5.2 模糊半径对滚动列表帧率的影响
shadow 的本质是离屏纹理加高斯模糊。组件越大、模糊半径越大,GPU 需要处理的像素就越多。一个卡片还好,如果 List 里同时渲染 20 张带大阴影的卡片,滚动时整列表都在重新计算阴影,帧率能掉到让你怀疑设备性能。
解决思路有三个方向。
第一,缩小阴影半径和透明度,视觉上通过多层阴影骗过眼睛,而不是硬堆一个超大 radius。
第二,把带阴影的卡片预先做成图片资源,用 .resizable() 九宫格拉伸。这样滚动时 GPU 只做纹理贴图,不做模糊运算,性能是最好的。代价是阴影形态固定,不灵活。
第三,滚动过程中不要动态改 shadow 参数。很多开发者喜欢根据滚动位置实时调整阴影,这等于每帧都触发一次离屏重绘,卡顿是必然的。如果需要滚动反馈,改背景色或透明度会更划算。
5.3 动画中阴影抖动的处理经验
给 shadow 的 radius 或 offset 做动画,视觉上很容易出现边缘抖动,尤其是模糊半径数值不断变化时。原因不在动画曲线,而在于 shadow 的模糊计算是逐帧重算的,圆角和模糊之间会有轻微的抗锯齿差异。
我现在的处理习惯是:动画过程中不直接改 shadow 的 radius,而是对透明度或组件缩放做动画。透明度变化几乎不增加计算量,缩放变化也只是整体变换,不会触发模糊参数重算,两者组合起来就能模拟出“阴影变淡 / 变近”的效果。
typescript复制.shadow({
radius: 32,
color: 'rgba(0, 0, 0, 0.12)',
offsetY: 16
})
.opacity(this.hovered ? 1 : 0.85)
.scale({ x: this.hovered ? 1.0 : 0.94, y: this.hovered ? 1.0 : 0.94 })
.animation({ duration: 200, curve: Curve.EaseOut })
这样阴影参数始终不变,动画只改变组件的透明度和整体缩放,视觉反馈依然明显,但渲染成本低很多,也不会出现模糊边缘抖动。
6. 手写一个可复用的“影子与投影模拟”Demo
6.1 工程准备与真机调试
在 DevEco Studio 里新建一个 Empty Ability 工程,语言选 ArkTS。写完代码后,我建议直接用真机验证阴影效果,尤其是想确认边缘抗锯齿和色彩表现时。我这边用的是 HarmonyOS 4.2 的设备,走 hdb 调试通道连接,比模拟器更接近真实渲染结果。
有一点值得提醒:模拟器和真机在阴影的颜色深浅上有细微差异,如果发现同一个 rgba 参数在两边的观感不一样,先看设备是否开启了不同的屏幕色彩模式,再决定是否微调。
6.2 核心页面代码与效果说明
下面这个 Demo 把前面提到的几种手段组合在一起:一个卡片列表,卡片默认带两层阴影,点击时切换按压态。用 @State 控制海拔状态,用 Stack 模拟多层阴影。
typescript复制@Entry
@Component
struct ShadowDemoPage {
@State activeIndex: number = -1;
@Builder
card(index: number) {
Column() {
Text('卡片 ' + index)
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text(this.activeIndex === index ? '按压态' : '悬浮态')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 8 })
}
.width(160)
.height(120)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.activeIndex === index ? '#F8FAFC' : '#FFFFFF')
.borderRadius(16)
.shadow({
radius: this.activeIndex === index ? 8 : 24,
color: this.activeIndex === index ? 'rgba(15,23,42,0.06)' : 'rgba(15,23,42,0.10)',
offsetY: this.activeIndex === index ? 2 : 12
})
.animation({ duration: 180, curve: Curve.EaseOut })
.gesture(
GestureGroup(GestureMode.Exclusive,
TapGesture()
.onAction(() => {
this.activeIndex = this.activeIndex === index ? -1 : index
})
)
)
}
build() {
Column({ space: 20 }) {
Text('影子与投影模拟')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.margin({ top: 40 })
Row({ space: 20 }) {
this.card(1)
this.card(2)
}
Row({ space: 20 }) {
this.card(3)
this.card(4)
}
}
.width('100%')
.height('100%')
.padding(20)
.backgroundColor('#F1F5F9')
}
}
运行起来后,点击卡片,卡片阴影会从大而淡变成小而实,背景颜色也会跟着变深,模拟出“按下去”的反馈。这个模式适合直接套用到商品卡、文章卡、工具卡片上。
6.3 深色模式下的阴影适配
深色模式下,纯黑色阴影几乎不可见,而且大面积黑色投影会让页面显得更脏。我自己的做法是:把阴影颜色定义到资源文件里,浅色模式用低透明度黑,深色模式用半透明白,做出一种微弱的“光晕”效果,让卡片从深色背景里浮出来。
json复制{
"color": [
{
"name": "card_shadow",
"value": "#14000000"
}
]
}
在 dark/element/color.json 里再放一个同名的颜色资源,value 改成 #0DFFFFFF 这类浅色低透明度值,代码里通过 $r('app.color.card_shadow') 引用。这样切换系统深浅色时,阴影会自动适配,不需要在代码里写判断。
说句题外话,我做这套 Demo 时最大的体会是:阴影效果没有“调一次就通用”的参数,不同尺寸、不同圆角、不同背景下的最优值都不一样。与其背参数,不如理解每一层阴影在这个视觉场景里负责什么——哪些负责接触感,哪些负责环境感,哪些只是在骗眼睛。把这层想明白,写出来的阴影就不会再被设计打回了。
