1. 实例背景与设计思路
这一期是 HarmonyOS 应用实例里的第 178 个主题,名字叫“影子与投影模拟”。我第一次看到这个选题时也有点意外,阴影还能单独占一期?但真到了项目里才发现,卡片是否“立体”、按钮是否有“可按”的暗示、弹窗有没有“浮在页面上方”的层级感,几乎全靠阴影参数撑着。没有一套能灵活调整的投影方案,UI 做出来就是平的,交互反馈也显得很木。
1.1 场景痛点:为什么需要一套“阴影与投影模拟”方案
先讲一个我实际踩过的例子。有一次做商城首页的限时秒杀卡片,设计师给的视觉稿里,卡片下方是一个灰色半透明渐变。我当时直接把渐变图片切出来放在卡片底部,看起来倒也没什么问题,可一旦切到深色模式,这张渐变图就完全没法用了,灰底在深色背景上特别突兀。后来换成 ArkUI 原生的 shadow 属性,根据全局颜色变量动态生成投影颜色,深色模式下一行代码自动换色,问题才彻底解决。
这个经历让我想明白了一件事:在 HarmonyOS 应用里,想要把界面的立体感做稳定,必须掌握原生的投影能力,而不是依赖资源切图。本实例围绕三类高频需求展开:
- 卡片悬浮投影,比如首页瀑布流中的商品卡、天气卡、个人中心的功能卡。
- 按钮按压抬升投影,实现“按下去、松手弹回来”这种符合物理直觉的反馈。
- 落地投影,模拟一个图标或悬浮按钮距离桌面有一定高度时,底部出现的柔光渐隐阴影。
这三类需求覆盖了日常开发中至少八成和阴影相关的场景。把它们吃透,后面遇到再复杂的视觉稿,也能自己拆解成基础能力来组合。
1.2 知识点覆盖与预期效果
本项目用到 ArkUI 声明式语法中的几个核心能力:.shadow() 阴影、.scale() 缩放、.rotate() 旋转、.animation() 动画,以及 Slider 滑动组件、@State 状态管理、@Styles 公共样式。我们会用一个页面,把“静态阴影、动态阴影、可交互阴影”全部跑一遍。
最终做完的效果大概是这样的:
- 页面上方有三张示例卡片,分别使用普通阴影、多层复合阴影、落地渐变投影。
- 页面中间有一个实时参数调节区,通过三个
Slider分别控制阴影的模糊半径、纵向偏移和透明度,拖动时卡片阴影实时变化。 - 点击或长按卡片时,卡片微微缩小,阴影同时向外扩散,松开后恢复,模拟手指按下去再弹起的物理反馈。
这套东西做完以后,可以直接当模板用到自己的项目里。下面先从 API 原理讲起,再逐步给出可运行的代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心 API 与渲染原理
2.1 四个关键参数:radius、color、offsetX、offsetY
shadow 是 ArkUI 内置通用属性,支持在组件上绘制投影。最常用的写法是给一个 ShadowOptions 对象:
typescript复制.shadow({
radius: 20,
color: 'rgba(0, 0, 0, 0.3)',
offsetX: 0,
offsetY: 8
})
参数含义和注意点我用表格整理一下:
| 参数 | 类型 | 默认值 | 作用 | 实操注意点 |
|---|---|---|---|---|
| radius | number | 0 | 阴影模糊半径,值越大边缘越弥散 | 小卡片建议 8-12,大卡片建议 16-24,不要一上来就 30+ |
| color | ResourceColor | 半透明黑 | 阴影颜色 | 强烈建议用带透明度的颜色,不要用纯黑 |
| offsetX | number | 0 | 水平偏移,正方向向右 | 配合光照方向,一般右侧光源就用正值 |
| offsetY | number | 0 | 垂直偏移,正方向向下 | 顶部光源典型值是 4-12 |
这里最关键的是 radius 和 color 的 alpha 值配合。很多人第一次写阴影,把 radius 拉到 30,颜色用纯黑 #000000,出来的效果又脏又重,整个页面像蒙了一层灰。我的习惯是:颜色透明度控制在 0.15 到 0.4 之间,半径不要超过组件短边的四分之一。例如一张 120vp 宽的卡片,radius 用 12 到 18 就已经很有层次感,再大就“糊”了。
2.2 shadow 与 elevation、ShadowStyle 的差异
shadow 属性之外,ArkUI 还提供了另一个和阴影相关的属性 elevation。简单理解:
shadow是让你精确控制阴影的每一个参数,属于“自定义模式”。elevation是系统按照层级高度自动生成阴影,只需要给一个数值,适合快速实现“抬升感”。
这两者不是替代关系,而是分工不同。我在实际项目里的选型规则是:需要精细调节视觉风格、要做动态投影的时候用 shadow;只是想让按钮或卡片有点浮起感、又不想调参的时候用 elevation。比如页面右上角的“发布”悬浮按钮,我就直接 .elevation(8),一行搞定。
另外,如果你的 API 版本支持,还可以直接使用预设的 ShadowStyle,例如:
typescript复制.shadow(ShadowStyle.OUTER_DEFAULT)
这种写法方便是方便,但定制化空间很小。本实例要模拟不同维度的投影,所以主要使用 ShadowOptions 方式。
2.3 阴影的渲染边界:为什么有时候阴影是方的
理解 shadow 的渲染逻辑,能帮你避开很多坑。shadow 本质上是沿着组件本身的绘制区域,向外做一次模糊和偏移。也就是说,组件有明确的宽高、有背景色或背景图,阴影才有依附对象。如果组件本身透明、或者宽高为 0,阴影自然就无从谈起。
更常见的问题是圆角。假设你在父容器上设置了 borderRadius(16),但 shadow 却加在一个没有背景色、宽高为 0 的子节点上,出来的阴影就可能是矩形。正确做法是让 shadow 和作用了 borderRadius、backgroundColor 的节点保持同一个。ArkUI 的阴影会尽量跟随组件的圆角信息,只要这几个属性在同一个节点上,圆角矩形卡片就能得到圆角阴影。
3. 三种常用投影效果实现
3.1 普通卡片阴影:最基础的投影
先写一个最常用的白色圆角卡片,背景色、圆角、阴影全部放在同一个 Column 上:
typescript复制@Entry
@Component
struct CardShadowDemo {
build() {
Column({ space: 24 }) {
Column({ space: 8 }) {
Text('HarmonyOS 卡片投影')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('普通阴影:一层模糊,视觉轻快')
.fontSize(13)
.fontColor('#999999')
}
.width(200)
.height(120)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({
radius: 12,
color: 'rgba(0, 0, 0, 0.2)',
offsetX: 0,
offsetY: 6
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1F3F5')
}
}
这段代码里,backgroundColor(Color.White) 让卡片有绘制区域,borderRadius(16) 决定阴影跟随圆角,shadow 提供投影。注意三个属性都在同一个 Column 节点上,缺一个都可能出现阴影异常。这段代码跑起来,就是一个很标准的卡片浮层效果,适合做商品卡、消息卡、功能入口。
3.2 多层复合阴影:模拟真实光线投影
真实世界里的阴影并不是一层,而是多层叠加:靠近物体的位置颜色深、范围小;离物体稍远的位置颜色浅、范围大。纯用一层 shadow 往往做不到这种过渡,解决办法是用两层嵌套容器,分别画“近影”和“远影”。
typescript复制@Entry
@Component
struct MultiLayerShadowDemo {
build() {
Column() {
// 外层负责远影
Column() {
// 内层负责近影和内容
Column({ space: 8 }) {
Text('复合阴影')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('近深远浅,过渡更自然')
.fontSize(13)
.fontColor('#999999')
}
.width(200)
.height(120)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({
radius: 6,
color: 'rgba(0, 0, 0, 0.3)',
offsetX: 0,
offsetY: 2
})
}
.width(200)
.height(120)
.borderRadius(16)
.shadow({
radius: 24,
color: 'rgba(0, 0, 0, 0.18)',
offsetX: 0,
offsetY: 12
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1F3F5')
}
}
原理很简单:内层阴影半径小、透明度高,模拟物体紧贴桌面时那一圈清晰的阴影;外层阴影半径大、透明度低,模拟光线散射后形成的柔和投影。两层叠在一起,视觉上比单层阴影柔和很多,也比较接近 Material Design 里“表面抬升”的质感。
这种写法适合用在需要强调层级的大卡片上,比如金刚区入口、个人中心头像卡片。但注意,嵌套容器会让布局层级变多,列表页里如果每个 item 都这么写,性能压力会偏大,建议只在重点模块使用。
3.3 落地投影:渐变色块加模糊
有时候我们想要的效果不是卡片四周有阴影,而是卡片底部“压”着一个软软的光斑,就像手机悬浮按钮离开桌面一小段距离。这时候用 linearGradient 加 blur 实现比单纯 shadow 更可控:
typescript复制@Entry
@Component
struct GroundShadowDemo {
build() {
Stack({ alignContent: Alignment.Bottom }) {
// 模拟落地投影
Column()
.width(120)
.height(24)
.borderRadius(12)
.linearGradient({
colors: [['rgba(0, 0, 0, 0.35)', 0], ['rgba(0, 0, 0, 0)', 1]]
})
.blur(8)
.translate({ y: 8 })
// 悬浮卡片
Column({ space: 8 }) {
Text('悬浮按钮')
.fontSize(16)
.fontWeight(FontWeight.Bold)
}
.width(100)
.height(100)
.backgroundColor(Color.White)
.borderRadius(24)
.shadow({
radius: 20,
color: 'rgba(0, 0, 0, 0.25)',
offsetX: 0,
offsetY: 10
})
.translate({ y: -30 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1F3F5')
}
}
这个方案的思路是:把投影当作一个独立的视觉元素,放在悬浮物下方,通过 linearGradient 控制光斑从中心到边缘的衰减,再用 blur 做一次整体柔化。相比纯 shadow,渐变投影更容易控制“光斑的宽窄”和“颜色衰减速度”,想要明显一点就提高透明度、加大宽度,想要低调一点就把透明度调低。
需要注意的是,Stack 的 alignContent 加上 translate 偏移要反复调,确保投影恰好落在卡片底部偏下的位置。这个方案尤其适合首页悬浮按钮、机器人对话的输入按钮这类“悬浮在底部”的控件。
4. 动态投影与交互联动
4.1 用 Slider 实时调节阴影参数
静态阴影只是第一步,很多场景还需要“可调”。比如主题编辑器、卡片风格配置页,用户拖一个滑块,阴影的深度和柔和度要实时变化。这里我们用 @State 绑定三个滑块值,再把这几个值拼到 shadow 里:
typescript复制@Entry
@Component
struct ShadowSliderDemo {
@State radius: number = 16
@State offsetY: number = 8
@State alpha: number = 30 // 0-100,使用时分母100
build() {
Column({ space: 32 }) {
// 展示卡片
Column({ space: 8 }) {
Text('实时调节')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('拖拽下方滑块,阴影会跟着变化')
.fontSize(13)
.fontColor('#999999')
}
.width(220)
.height(140)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({
radius: this.radius,
color: 'rgba(0, 0, 0, ' + this.alpha / 100 + ')',
offsetX: 0,
offsetY: this.offsetY
})
// 调节区
Column({ space: 16 }) {
Text('模糊半径:' + Math.round(this.radius))
Slider({ value: this.radius, min: 0, max: 40, style: SliderStyle.OutSet })
.onChange((value: number) => {
this.radius = value
})
Text('纵向偏移:' + Math.round(this.offsetY))
Slider({ value: this.offsetY, min: 0, max: 30, style: SliderStyle.OutSet })
.onChange((value: number) => {
this.offsetY = value
})
Text('阴影透明度:' + Math.round(this.alpha) + '%')
Slider({ value: this.alpha, min: 0, max: 80, style: SliderStyle.OutSet })
.onChange((value: number) => {
this.alpha = value
})
}
.width('80%')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1F3F5')
}
}
这里有个小细节:透明度滑块我选的是 0 到 80,而不是 0 到 100。原因是阴影透明度超过 0.8 之后基本就是一团黑,不仅没有层次感,还会挡住后面的内容。限制在 0 到 0.8 范围,用户怎么拖都不会拖出太夸张的效果。
这个“滑块调参”的代码后续扩展性很强。你可以在里面继续加偏移方向、阴影颜色选择器,甚至把多套参数组合成主题模板,用户选一套直接应用到全 App。
4.2 按压抬升:让阴影跟随手指动起来
光调节参数还不够,交互反馈才是阴影最能发挥价值的地方。下面这个示例,通过 onTouch 监听手指按下和抬起状态,让卡片按下的同时缩小一点、阴影向外扩散,模拟“卡片被手指按下去又弹回来”的物理感。
typescript复制@Entry
@Component
struct PressFeedbackDemo {
@State pressed: boolean = false
build() {
Column() {
Column({ space: 8 }) {
Text('按压反馈')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('按下时缩小,阴影扩散')
.fontSize(13)
.fontColor('#999999')
}
.width(200)
.height(130)
.backgroundColor(Color.White)
.borderRadius(16)
.scale({
x: this.pressed ? 0.96 : 1,
y: this.pressed ? 0.96 : 1
})
.shadow(this.pressed ? {
radius: 28,
color: 'rgba(0, 0, 0, 0.35)',
offsetX: 0,
offsetY: 14
} : {
radius: 10,
color: 'rgba(0, 0, 0, 0.2)',
offsetX: 0,
offsetY: 4
})
.animation({ duration: 160, 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
}
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1F3F5')
}
}
这段代码的精髓在于 scale 和 shadow 同时变化。只缩小卡片不放大阴影,会让人感觉卡片被按进屏幕里;只放大阴影不缩小卡片,又缺少按压的“实感”。两者配合,才接近真实物理世界里手指压下一个纸片的效果。
animation({ duration: 160, curve: Curve.EaseOut }) 的作用是让状态切换有一个平滑过渡,而不是瞬间跳变。160 毫秒是我多次调试后觉得比较舒服的按压反馈时长,太短会觉得生硬,太长又显得拖沓。
4.3 旋转与光源方向:投影跟着卡片角度变
还有一种常见场景:卡片带了一点倾斜角度,比如 3D 翻转、封面旋转展示。这时候阴影的偏移方向可以跟随旋转角度变化,让光源看起来是固定方向的。示例代码如下:
typescript复制@Entry
@Component
struct RotateShadowDemo {
@State angle: number = 0
build() {
Column({ space: 24 }) {
Column({ space: 8 }) {
Text('旋转阴影')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('角度变化时,投影向反方向偏移')
.fontSize(13)
.fontColor('#999999')
}
.width(200)
.height(120)
.backgroundColor(Color.White)
.borderRadius(16)
.rotate({
x: 0,
y: 0,
z: 1,
angle: this.angle
})
.shadow({
radius: 18,
color: 'rgba(0, 0, 0, 0.25)',
offsetX: -this.angle / 10,
offsetY: 8
})
Slider({ value: this.angle, min: -30, max: 30, style: SliderStyle.OutSet })
.width('80%')
.onChange((value: number) => {
this.angle = value
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1F3F5')
}
}
这里把 offsetX 设置成 -this.angle / 10,意思是卡片往右旋转时,阴影往左偏移,模拟光源来自固定的左上方。实际项目中你可以根据设计稿调整这个比例的系数,不一定非要是 10,多试几个值找到最自然的手感。
5. 常见问题与性能优化
5.1 阴影不显示或形状不对,先查这四个地方
我在社区里看到很多人问“为什么我的 shadow 不生效”,绝大多数原因都集中在下面几个点:
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 阴影完全不显示 | 组件宽高为 0,或 shadow 加在了透明容器上 |
确保组件有明确的 width、height 和 backgroundColor |
| 阴影是方形,不是圆角 | borderRadius 和 shadow 不在同一个节点 |
把 borderRadius、backgroundColor、shadow 放到同一节点 |
| 阴影发黑、很脏 | color 透明度太高,或 radius 太大 | alpha 控制在 0.15-0.4,radius 从 12 开始调 |
| 阴影只出现在一边,很生硬 | 偏移量设置过大,且 radius 过小 | 先调大 radius,再调 offset,别让阴影和主体完全脱节 |
第一个问题最常见。很多 ARKUI 开发者习惯把 shadow 加在 Column 外层,但内容其实是一张带透明通道的图片。这时候阴影会按照 Column 的边界框绘制,而不是图片的轮廓,于是出现一个巨大的矩形阴影。解决办法是把 shadow 直接加到图片或内容节点上,或者先用 clip 裁出需要的形状再绘制阴影。
第二个问题的原因我在前面渲染原理那里讲过:阴影的圆角跟随节点自身的圆角信息。所以写代码时尽量把视觉效果相关的属性堆在同一个节点,不要一层设置背景、一层设置圆角、再一层设置阴影,拆得太散容易出各种不可预期的结果。
5.2 列表滚动卡顿:阴影性能的取舍
阴影在视觉上很有用,但也是渲染性能的消耗大户。尤其是滚动列表里,每个 item 都带一个大半径阴影,GPU 要实时计算大量模糊,很容易出现掉帧。
我的优化思路优先级如下:
- 能用图片切图的地方,就用一张带透明通道的预渲染阴影图片。这不是倒退,而是性能和视觉之间的现实取舍。
- 必须用
shadow时,缩小radius。半径越大,模糊计算的采样范围越大,开销指数级上升。从 20 减到 10,通常能明显改善滚动流畅度,视觉上差距并不大。 - 避免在滚动容器里嵌套多层阴影。如果确实需要多层效果,可以把列表项改为静态预览图,或者在滚动结束后再渲染阴影。
- 大面积阴影尽量避免。比如全屏弹层的遮罩背景,用半透明颜色加
blur(8)会比一个大 radius 的shadow更高效,视觉上也更干净。
我自己的经验是:一个页面内同时存在 3 到 5 个小面积阴影,基本没什么性能压力;但如果在瀑布流里每个卡片都上 24 的 radius,中低端设备会非常吃力。遇到这种情况,我会优先砍 radius,并把阴影颜色透明度从 0.3 降到 0.2,让视觉权重降低,换回流畅度。
5.3 真机调试时的视觉差异与调优方法
模拟器里看到的阴影效果和真机经常有出入,尤其是颜色深度和发光感这一点。模拟器的色彩管理可能和真机屏幕不一样,同一个 rgba(0,0,0,0.25) 的阴影,在模拟器上可能显得很淡,到了真机上又偏重。
因此我在做阴影相关需求时,一定会上真机确认效果。建议你在 DevEco Studio 里使用真机调试,在真机上实际观察卡片在不同屏幕亮度下的表现。一般调优路径是:
- 先从一个偏小的 radius 开始,比如 8。
- 调 offsetY 到 4 到 8,让阴影方向和顶部光源一致。
- 最后调 alpha,从 0.2 起步,逐渐增加到视觉上“刚好能感知层级”的程度,不要一步拉到 0.5。
这样一步步逼近,比上来就设一组大参数再一点点往回收,效率高得多。另外,如果你在页面里使用 @Styles 统一定义阴影,换肤或改风格时会非常省事,例如:
typescript复制@Styles
function commonShadow() {
.shadow({
radius: 12,
color: 'rgba(0, 0, 0, 0.2)',
offsetX: 0,
offsetY: 6
})
}
这个公共样式可以直接应用到多个组件上,保证全站卡片阴影风格统一。缺点是不能动态读取 @State,所以需要实时变化的部分我还是会单独写在组件属性里。
5.4 动态值拼接和类型转换的小坑
在 ArkTS 里写动态阴影时,最容易翻车的是颜色字符串拼接。例如:
typescript复制color: 'rgba(0, 0, 0, ' + this.alpha / 100 + ')'
这里 this.alpha / 100 的结果可能是小数,拼出来就是 rgba(0, 0, 0, 0.3),这是没问题的。但如果 alpha 是整数且你忘了除 100,拼出来就是 rgba(0, 0, 0, 30),此时透明度直接失效,阴影会变成一块纯黑。我的习惯是统一用一个整数状态表示百分比,计算时再除 100,避免在字符串里拼一个很大的数进去。
另外,radius 和 offsetX、offsetY 都接受 number 类型,如果你把它们绑定到 Slider 的浮点返回值上,直接透传给 shadow 即可,不需要额外取整。取整只在显示文本的时候做,比如 Math.round(this.radius)。
我个人最初做阴影调优时,总想把每个效果都做到“一眼惊艳”,结果就是参数越调越大,最后卡片完全被黑影包住,反而失去了层次。后来调整思路,改成以“克制的层次感”为目标,阴影能让人感受到卡片浮起来就停手,这个度才是最合适的。你在项目里遇到阴影效果不好的时候,也可以先用最低一组参数跑通,再一点点加上去,而不是一开始就堆满。这样既能控制视觉,也给性能留了余地。
