先问个问题:有多少人在鸿蒙应用里做卡片阴影时,直接给组件挂一个.shadow(),然后发现效果要么发灰、要么边缘生硬、要么列表滑动直接掉帧?这个看着不起眼的小功能,一旦牵扯到“模拟真实投影”,坑其实比想象中多。
这个实例是HarmonyOS应用开发里的影子与投影模拟,核心解决两件事:一是用系统自带能力快速做出层次感,二是在系统能力不够用的时候,通过自绘、预渲染等手段模拟出接近真实物理效果的投影。适合正在做鸿蒙应用UI定制、或者想优化卡片质感的开发者参考,不管你是刚接触ArkUI,还是已经写过不少页面,这篇文章里的参数调整逻辑和性能优化思路应该都能直接用上。
1. 内容整体设计与思路拆解
1.1 影子效果为什么不能只靠一个属性
很多从Web前端转过来的同学,习惯性地把shadow当成box-shadow的替代品,设置一下颜色、模糊半径、偏移就完事了。但在鸿蒙ArkUI里,shadow的设计思路和CSS其实有微妙差别,如果不理解底层机制,很容易做出“贴纸感”很强的阴影,也就是阴影和组件之间没有过渡,看起来像硬生生叠了一层灰边。
整个投影模拟的核心逻辑可以拆成三层:
- 第一层:系统原生的shadow样式,基于组件的轮廓自动生成模糊阴影,适合标准矩形、圆角卡片这类规则形状。
- 第二层:通过自定义绘制或者在组件下方垫一层渐变/模糊图形,手动模拟投影,适合异形组件、图片抠图、或者需要特殊光效的场景。
- 第三层:用预渲染的位图或者九宫格拉伸替代实时模糊计算,这是性能敏感场景下最稳妥的方案。
我在实际项目里的选型思路是这样的:能用第一层解决的绝不上第二层,第二层能解决的就不碰第三层,因为每多一层手动模拟,就意味着多一份适配工作量——不同屏幕密度下模糊效果可能不一致,不同系统版本上渲染结果也可能有差异。
1.2 一个典型场景:卡片悬浮层次
举一个最常见的例子,首页卡片列表。产品经理的要求通常是“卡片在滑动的时候要有层次感,悬浮起来的卡片要有明显的投影”。这个需求听着简单,但真正做起来会牵扯到两个问题:
- 阴影的范围、模糊程度、透明度怎么组合才自然,而不是一团黑或者一片灰。
- 卡片在按压、拖拽、弹起的过程中,阴影状态如何平滑切换。
我见过不少项目在实现按压反馈时,直接给卡片加一个scale缩放,然后阴影不动,结果卡片缩小了影子还保持原样,视觉上非常割裂。正确做法是让阴影的偏移和模糊半径跟着组件状态一起变化:卡片按下去时,offsetY增大、透明度降低、模糊半径稍微增加,这样看起来就像卡片真的被“按”进了背景里。
后面会详细讲这些参数的组合策略,先说结论:任何“自然”的投影,都不是单一参数决定的,而是半径 + 偏移 + 透明度 + 颜色这四者的联动结果。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 shadow属性详解与参数联动
ArkUI里直接控制阴影的接口是shadow()方法,核心参数包括:
| 参数 | 类型 | 作用 | 注意事项 |
|---|---|---|---|
| radius | number | 模糊半径,控制阴影边缘的柔和程度 | 值越大越模糊,但计算开销也越大 |
| color | Color/string | 阴影颜色 | 建议带透明度,纯黑会很生硬 |
| offsetX | number | X轴偏移,单位vp | 正数向右,负数向左 |
| offsetY | number | Y轴偏移,单位vp | 正数向下,负数向上 |
| type | ShadowType | 阴影类型 | COLOR或OUTER_FLOAT,影响实现效果 |
我推荐一个起步参数组合,适合白底页面的卡片投影:
typescript复制.shadow({
radius: 20,
color: 'rgba(0, 0, 0, 0.08)',
offsetX: 0,
offsetY: 8
})
这个组合的特点是:模糊半径是偏移量的2倍以上,透明度很低,颜色不是纯黑而是偏灰的黑。从视觉原理上讲,真实环境中的投影是“越靠近物体边缘越实,越往外越虚”,radius的作用就是控制这个“虚”的过渡范围,而offsetY控制投影的方向感,透明度则模拟光的强度。
2.2 ShadowType的选择逻辑
ArkUI的ShadowType有两个关键枚举值需要区分:
ShadowType.COLOR:纯色阴影,效果类似于给组件外轮廓做一次颜色填充加模糊,性能开销相对可控。ShadowType.OUTER_FLOAT:浮动阴影,会产生一种“物体悬空”的视觉效果,边缘更柔和,过渡更自然,但实现时涉及更复杂的模糊计算。
我实测下来,OUTER_FLOAT在卡片提升视觉层次时效果好很多,尤其是在浅色壁纸背景下。但要注意,它对组件轮廓的依赖更强——如果你的组件设置了clip(true)把圆形裁剪了,阴影也会跟着变,有时候这不是你想要的。
一个实用技巧:如果不确定用哪种type,先在模拟器上切换对比一下,注意观察阴影边缘是否和组件边缘完全贴合,完全贴合说明阴影是“贴”在组件上的,适合扁平风格;有悬浮分离感的则更适合层次强调。
2.3 elevation与shadow的取舍
除了shadow,ArkUI里还有个容易被忽略的属性叫elevation,可以直接设置投影层级。它的底层逻辑更接近Android的elevation概念,根据层级值自动计算阴影的大小和透明度。
从我个人的使用体验来说,elevation适合做“统一层级管理”——比如你定义好一级卡片elevation是2,二级浮层是4,弹窗是8,整个页面的层级体系就非常清晰。但它的问题是定制性不够强,想微调阴影的模糊半径和偏移量就没那么自由了。
所以我的建议是:层级体系用elevation管理,视觉细节用shadow微调。两者同时使用时,shadow的优先级更高,会对elevation生成的默认阴影产生覆盖作用。这个组合拳在很多设计系统里都有类似实现思路。
3. 实操过程与核心环节实现
3.1 环境准备与基础工程结构
在动手写代码之前,先把环境准备好。我使用的是DevEco Studio 4.0及以上版本,API 9以上的HarmonyOS工程都支持下面的写法。如果你的IDE版本比较老,建议先升级,因为低版本对shadow属性的支持不完整,特别是ShadowType.OUTER_FLOAT在老版本上可能会出现不生效的情况。
创建一个空Ability工程后,页面结构建议这样组织:
typescript复制@Entry
@Component
struct ShadowDemoPage {
@State cardPressed: boolean = false;
build() {
Column({ space: 20 }) {
// 卡片阴影展示区域
this.buildShadowCard()
// 投影模拟展示区域
this.buildCustomShadow()
}
.width('100%')
.height('100%')
.padding(20)
.backgroundColor('#F5F5F5')
}
}
这里把不同的效果拆成独立构建函数,方便后续逐个调试。@State cardPressed用来驱动按压状态的UI切换,这是后面做动态阴影的基础。
3.2 基础静态阴影实现:卡片从0到1
先实现一个最常规的卡片投影。假设卡片本身是一个圆角矩形,里面有缩略图和文字:
typescript复制@Builder
buildShadowCard() {
Column() {
Row() {
Image($r('app.media.thumbnail'))
.width(60)
.height(60)
.borderRadius(12)
Column() {
Text('HarmonyOS 设备协同')
.fontSize(16)
.fontWeight(FontWeight.Bold)
Text('跨设备流转演示')
.fontSize(13)
.fontColor('#666666')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding(16)
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({
radius: 20,
color: 'rgba(0, 0, 0, 0.08)',
offsetX: 0,
offsetY: 8
})
}
这段代码里有一个很容易被忽略的细节:阴影加在最外层Column上,而不是加到内部的Row上。因为shadow是跟随组件轮廓的,如果加在Row上,阴影形状会变成不规则的外接轮廓,视觉上比较乱,而且Row没有背景色时阴影会透出后面的元素。
同时注意,.borderRadius(16)和.shadow()同时设置在Column上时,阴影会沿着圆角轮廓生成,而不是生成一个矩形阴影。这就是为什么我之前强调“轮廓决定阴影形状”,圆角半径越大,阴影越柔和。
3.3 动态交互阴影:按压反馈与悬浮效果
静态阴影做好之后,接下来做一个动态效果。我的方案是给卡片加一个点击态,通过控制阴影的偏移量和透明度模拟“按下去”的反馈:
typescript复制@State cardPressed: boolean = false;
buildShadowCard() {
Column() {
// 卡片内容
}
.backgroundColor(this.cardPressed ? '#FAFAFA' : Color.White)
.borderRadius(16)
.shadow({
radius: this.cardPressed ? 24 : 20,
color: this.cardPressed ? 'rgba(0, 0, 0, 0.04)' : 'rgba(0, 0, 0, 0.08)',
offsetX: 0,
offsetY: this.cardPressed ? 4 : 8
})
.animation({ duration: 150, curve: Curve.EaseOut })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.cardPressed = true;
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.cardPressed = false;
}
})
}
这里有几个参数变化值得细说:
- 按下去时
offsetY从8降到4,表现的是“卡片被往下压了,投影离卡片更近了”。 - 透明度从0.08降到0.04,因为按钮被按下时更贴近背景,投影自然变淡。
radius从20增加到24,因为接触面积变大,边缘更柔和。- 加了
animation做150ms的过渡,EaseOut曲线让按下过程有阻尼感,抬起来时又不会拖沓。
我在给多个项目做这种动态阴影时发现,150ms的时长很关键。太短(比如80ms)会显得生硬,太长(比如300ms)又会被用户感知为“卡”。如果你想让交互更有弹性,可以试试Curve.SpringCurve,但要注意别让回弹幅度太大,否则视觉上像“果冻”,不适合商务类App。
3.4 自定义投影模拟:不规则形状与图片抠像
当一个元素不是矩形,比如一个异形图标、一张抠好的PNG图片,或者一个自定义绘制的形状时,shadow属性往往会失效或生成不规则的杂乱阴影。原因很简单:shadow是基于组件矩形边界计算的,而不是基于组件内部的具体像素。
这时候就需要手动模拟投影了。我的方案是:在目标组件下面垫一个用模糊渐变绘制的图形。
用一个简单的Canvas绘制来演示:
typescript复制@Builder
buildCustomShadow() {
Stack({ alignContent: Alignment.Center }) {
// 底层阴影
Canvas(this.customShadowContext)
.width(120)
.height(120)
.onReady(() => {
const ctx = this.customShadowContext;
// 绘制一个径向渐变模拟阴影
const gradient = ctx.createRadialGradient(60, 60, 5, 60, 60, 40);
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.12)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(60, 60, 50, 0, Math.PI * 2);
ctx.fill();
})
// 偏移模拟光照方向
.translate(0, 8)
// 上层主体
Image($r('app.media.logo'))
.width(80)
.height(80)
}
}
这段代码的思路是:先画一个半径50的圆形径向渐变,渐变中心在圆心,透明度从0.12到0,在视觉上形成一个“中间深、边缘淡”的光晕,再通过translate整体往下偏移8个vp,模拟从上方打光时投影落在地面的效果。
这种方式的优点是可以完全控制阴影的形状、渐变范围、偏移方向,甚至可以做多个方向的光源模拟。缺点是代码量增加,并且需要针对不同尺寸的组件微调createRadialGradient的参数。
一个从实际项目里总结的经验:底层阴影图形的最佳尺寸一般是主体组件尺寸的1.5倍到1.8倍,超出部分用于渐变的过渡区域。如果阴影层和主体一样大,渐变过渡会被硬生生截断,边缘会出现一条非常明显的分界线。
3.5 多层阴影叠加:模拟真实环境光
真实世界里的投影往往不是一层,而是多层不同模糊程度、不同透明度的阴影叠加出来的效果。比如卡片正下方的投影比较实,离得远一些的地方还有一层淡淡的泛光。
ArkUI的shadow()方法本身只支持一组参数,没法像CSS那样用逗号分隔多组阴影。要实现多层效果,可以用一个透明的外层容器,然后在外层容器内叠加多个带不同阴影的兄弟组件。
我实际写过一个页面底部导航栏的投影,用了三层阴影,效果比单层好不少:
typescript复制Stack({ alignContent: Alignment.Bottom }) {
// 第一层:大范围弱阴影,模拟环境光
Column()
.width('100%')
.height(60)
.backgroundColor(Color.Transparent)
.shadow({
radius: 32,
color: 'rgba(0, 0, 0, 0.06)',
offsetX: 0,
offsetY: -2
})
// 第二层:中等范围阴影,模拟轮廓边
Column()
.width('100%')
.height(60)
.backgroundColor(Color.Transparent)
.shadow({
radius: 16,
color: 'rgba(0, 0, 0, 0.08)',
offsetX: 0,
offsetY: -4
})
// 第三层:实底导航栏
Column() {
// 导航内容
}
.width('100%')
.height(60)
.backgroundColor(Color.White)
}
这里要注意一个关键点:每一层阴影的载体组件必须设置透明背景色,否则后面的层会遮挡前面的阴影。我最初直接放一个Column()没有设置背景,结果第二层的阴影被第一层的“实体背景”挡住了,排查了很长时间才发现。
另外,offsetY用负值是为了让阴影朝上投影,因为底部导航栏的光源通常来自于上方,向下投影会被屏幕边缘裁剪,向上投影才符合真实视觉。
4. 常见问题与排查技巧实录
4.1 阴影不显示或显示不全
这是遇到最多的问题,通常有三个原因:
组件被裁剪了。如果外层容器设置了.clip(true),或者使用了List/Scroll等滚动容器,默认情况下子组件的阴影会被裁剪掉。解决方案是在滚动容器的itemConstraintSize里预留阴影的扩展空间,或者对卡片外层再包一个不裁剪的容器,把clip放到更外层去。
阴影颜色透明度为0。调试阶段经常有人把color设置成Color.Transparent或者忘记写透明度,看起来就像没加阴影。建议先用一个明显的半透明白色或黑色测试,确认阴影正常后再改成目标颜色。
组件本身没有背景。如果阴影加在Text或Image这类没有背景的组件上,阴影会沿着文字或图片的边界走。文本的阴影会把文字的每一笔都模糊出来,效果和预期相差很大。所以阴影最好加在有背景色/背景图的外层容器上。
4.2 列表滑动掉帧
shadow在静态页面上用着还行,一旦放到List/Scroll里,滑动时会出现明显的卡顿。原因在于阴影需要实时计算模糊,特别是大范围radius和大尺寸卡片组合时,GPU负载会高很多。
我实测过一个半屏卡片列表,每项带20vp模糊半径的阴影,在低端设备上滑动帧率掉到二十几帧。当时定位到问题后做了两个优化:
- 把列表项的
shadow去掉,改成在外层容器一次性画一个统一的背景阴影。 - 对于必须保留的阴影,减少radius值,从20降到12,视觉差异不大,但性能明显提升。
另外一个小技巧:列表页的卡片阴影可以用预渲染图片替代。让设计先出一张带阴影的卡片底图,然后用.borderRadius裁剪成圆角,或者直接用Image当卡片背景。这样实时计算量就为零了,滑动丝滑很多。
4.3 阴影颜色在深色模式下失效
HarmonyOS的深色模式适配中有一个容易被忽略的问题:如果阴影颜色写死为rgba(0, 0, 0, 0.08),在深色背景下这个黑色阴影几乎是看不见的,而浅色模式下又可能显得太黑。
我的解决方案是使用资源限定符,在dark模式资源目录下配置不同的阴影颜色值:
code复制# 在resources/dark/element/color.json中
{
"color": [
{
"name": "card_shadow_color",
"value": "#66FFFFFF"
}
]
}
然后在代码里通过$r('app.color.card_shadow_color')引用,系统会自动根据深浅色模式切换。白色半透明阴影在深色模式下能营造出一种“透光”的感觉,比黑色阴影更自然。
4.4 阴影偏移出父容器边界
当一个卡片在列表的第一项时,如果它的offsetY是正数且值较大,阴影会往下延伸到父容器边界之外,然后被裁剪掉。这个问题在List组件里非常常见,因为List默认会裁剪子项。
两个处理方式:
- 给列表项设置
padding,下方留出阴影可以渲染的空间。 - 不用
List,改用Scroll+Column实现列表,然后关闭裁剪。
我通常选择第一种,因为List的复用机制对长列表性能友好太多,为了阴影去牺牲列表性能不值得。给每条item加一个padding({ bottom: 20 }),让阴影有地方画,就解决问题了。
5. 影子模拟的方案对比与应用场景扩展
5.1 三种阴影方案横向对比
把前面提到的几种方案放在一张表里对比,方便你在项目里做技术选型:
| 方案 | 适用场景 | 性能 | 定制性 | 实现成本 |
|---|---|---|---|---|
| shadow属性 | 标准卡片、按钮 | 中 | 中 | 低 |
| elevation属性 | 层级统一管理 | 中 | 低 | 低 |
| Canvas自绘渐变 | 异形组件、特殊光源 | 中高 | 高 | 高 |
| 预渲染图片 | 列表项、高频刷新区 | 高 | 中 | 中 |
| 多层阴影叠加 | 导航栏、浮层 | 中 | 高 | 中 |
我建议的选型逻辑很简单:如果你的页面是静态展示型(比如个人中心、设置页),直接用shadow最省事;如果是高频滑动列表(比如信息流、商品列表),优先考虑预渲染图片;如果UI设计稿里有特殊的投影渐变(比如光晕、弥散光),那就逃不掉Canvas自绘了。
5.2 从阴影到光效:投影模拟的进阶思路
影子模拟做到最后,很多需求会从“投影”延伸到“光效”。比如卡片边缘的高光、底部导航栏的弥散光、按钮的发光效果,这些本质上和阴影是同一套逻辑——都是通过模糊和渐变模拟真实世界的光照。
在HarmonyOS里,可以用linearGradient或者radialGradient配合模糊来实现简单的光效模拟,思路和Canvas渐变一致。我做一个搜索框的聚焦发光效果时,就是在输入框下面垫了一层蓝紫色渐变,透明度随聚焦状态变化,效果很接近Web端常见的“光晕搜索框”。
一个值得记住的原则:光的感知靠的是对比。阴影的颜色和透明度必须和页面背景形成对比才看得见,所以浅色背景下用低透明度黑,深色背景下用低透明度白,这是所有光效模拟的基本功。
5.3 系统版本适配与测试注意事项
最后说一下适配测试。HarmonyOS从API 9开始对shadow的支持基本一致,但不同系统版本在OUTER_FLOAT类型上的渲染细节有细微差别。我遇到过同一个工程在API 9的模拟器上阴影正常,在API 11的真机上阴影边缘多了一像素的白边,后来排查发现是系统在渲染透明边界的抗锯齿算法不一致。
建议测试时覆盖三个维度:
- 模拟器(快速验证逻辑)
- 真机低端机型(确认性能)
- 深色模式(确认颜色适配)
另外,真机上如果发现阴影有锯齿边,可以尝试给阴影组件加一个极小的padding(0.1)作为“渲染缓冲”,有时候能有效消除边缘瑕疵。
我在实际开发中还养成了一个习惯:把阴影相关的参数统一抽成常量或者主题变量,不要散落在各页面里。不同页面用同一套阴影规范,设计走查时改起来也方便。比如定义一套cardShadow = { radius: 20, offsetY: 8, opacity: 0.08 },所有卡片统一引用,更换风格时只需要改一个地方。这个习惯在我维护一个多页面应用时帮了大忙,各种阴影参数不再需要逐页排查替换。
