Flutter 骨架屏 Shimmer 实现:不用 transform 的扫光法实战
Flutter 骨架屏 Shimmer 实现:不用 transform 的扫光法实战
作者:FungLeo | 适用:Flutter 3.x
场景:列表页想做「骨架屏 + 扫光」,照着老教程写LinearGradient(transform: ...),结果编译直接不过。
前言
说实话,骨架屏这玩意儿现在基本算列表页的标配了。
我以前是不太在乎这个的,页面加载中就丢个转圈圈(CircularProgressIndicator)在屏幕正中间,能用就行。直到有一次我自己拿真机点了几十遍我们那个列表页,越点越烦——每次都是白屏一闪、转圈一秒、内容"啪"地弹出来,跳动感特别强,看着就廉价。
于是我决定上骨架屏。灰色块块摆好之后,效果确实好多了,但总觉得少点意思:静态的灰块看久了跟"卡死了"没啥区别。那就再加个扫光吧,让灰块上有一道浅色的亮带来回滑,一看就知道"我在加载,我还活着"。
结果就是这一步,把我卡了小半个下午。
网上一搜,全是LinearGradient(transform: GradientTransform(...))的写法,我复制过来,IDE 直接给我标红:
undefined_named_parameter: The named parameter 'transform' isn't defined.好家伙。那会儿我第一反应是"这参数被新版删了吧",然后就顺着这个错误的判断,自己摸了另一条路出来。这条路后来证明还挺好用,所以这篇就把两条路都写给各位看官,顺便把我当时判断错的地方也一并交代清楚。
一、先说那个报错,以及我判断错的地方
当时报错的代码大概长这样:
// ❌ 我当时抄来的写法,直接标红LinearGradient(transform:GradientRotation(0.5),// 报 undefined_named_parameterstops:const[0.0,0.5,1.0],colors:const[Color(0xFFEDEDED),Color(0xFFF5F5F5),Color(0xFFEDEDED)],)我当时的结论是:「transform这个参数在新版 API 里没了。」
然后我就照着这个结论绕道走了。但后来我被自己打脸了——Gradient(包括LinearGradient)其实是带transform参数的,类型是GradientTransform,GradientRotation也确实还在。我那个报错,八成是当时把它写到了不接受这个参数的地方(比如误挂在BoxDecoration上),或者项目里的 Flutter 版本跟我以为的对不上号。
所以这里先把话说清楚,免得误导各位看官:不是这个参数被删了。
那为什么我还要把这篇写出来?因为绕出来的那条路——平移stops——反而更简单、更好控,做简单扫光的时候我现在还在用。两条路我都贴,各位看官自己挑。
二、绕法:让 stops 随时间平移
思路特别朴素:扫光的本质就是「一道亮带在灰底上从左滑到右」。既然亮带的位置由渐变的stops决定,那我不动 transform,直接让 stops 随时间左右平移,不就等效了吗?
闲言少叙,上代码:
classShimmerBlockextendsStatefulWidget{constShimmerBlock({super.key,this.width,this.height=16,this.radius=8,});finaldouble?width;// 不传就撑满父级可用宽度finaldouble height;finaldouble radius;@overrideState<ShimmerBlock>createState()=>_ShimmerBlockState();}class_ShimmerBlockStateextendsState<ShimmerBlock>withSingleTickerProviderStateMixin{latefinalAnimationController_ctrl=AnimationController(vsync:this,duration:constDuration(milliseconds:1200),)..repeat(reverse:true);// 来回跑,不是单向循环@overridevoiddispose(){_ctrl.dispose();// 这行千万别漏,下面专门说super.dispose();}@overrideWidgetbuild(BuildContextcontext){returnAnimatedBuilder(animation:_ctrl,builder:(_,__){finalp=_ctrl.value;// 0 → 1 → 0finalshift=(p-0.5)*0.6;// 平移量,控制在 ±0.3returnContainer(width:widget.width,height:widget.height,decoration:BoxDecoration(borderRadius:BorderRadius.circular(widget.radius),gradient:LinearGradient(begin:Alignment.centerLeft,end:Alignment.centerRight,// stops 必须是递增的,所以每个都要 clamp 回 [0, 1]stops:[(0.0+shift).clamp(0.0,1.0),(0.5+shift).clamp(0.0,1.0),(1.0+shift).clamp(0.0,1.0),],colors:const[Color(0xFFEDEDED),// 底色Color(0xFFF5F5F5),// 亮带Color(0xFFEDEDED),// 底色],),),);},);}}跑起来就是一道浅色亮带在灰块上来回滑,OK,效果到位了。
几个点解释一下,我把关键决策摊开成一张表,方便各位看官直接抄:
| 决策点 | 我的取值 | 为什么要这样 |
|---|---|---|
| 动画时长 | 1200ms | 太快像闪烁、太慢像卡死,1 秒出头最自然 |
| 循环方式 | repeat(reverse: true) | 单向repeat到 1 会瞬跳回 0,有"闪回";来回走平滑 |
stops边界 | 每个都.clamp(0,1) | 平移后必有值跑出界,不夹就报"stops 非法" |
| 重建方式 | AnimatedBuilder | 只重建 builder 内小坨,几十个块同屏也省性能 |
- 为什么
stops要 clamp:LinearGradient要求stops单调不减、且落在[0, 1]区间内。平移之后必然会有值跑出界(比如1.0 + 0.3),不夹一下就要出问题。夹完之后三个点依然是递增的,所以视觉上没毛病。 - 为什么用
AnimatedBuilder而不是setState:AnimatedBuilder只重建 builder 里那一小坨,不会把整棵子树拖下水。骨架屏一屏可能有几十个块块,这点开销差别还是挺明显的。 width为什么可以不传:Container自己不带尺寸,宽度不传就跟着父级走。做骨架屏的时候,"标题行"我一般给个固定宽度(比如 120),"正文行"就让它撑满,看着更像真实内容。
三、另一条路:老老实实实现 GradientTransform
既然上面已经说了transform参数是在的,那这条路也顺手补全,省得各位看官还得自己去翻。
GradientTransform是个抽象类,你得自己写个子类,返回一个Matrix4:
/// 让整个渐变沿 X 轴平移的 transformclass_SlidingGradientTransformextendsGradientTransform{const_SlidingGradientTransform(this.slidePercent);finaldouble slidePercent;// -1 ~ 1@overrideMatrix4?transform(Rectbounds,{TextDirection?textDirection}){// 按控件自身宽度的百分比平移returnMatrix4.translationValues(bounds.width*slidePercent,0,0);}}用的时候把动画值喂给它:
LinearGradient(begin:Alignment.centerLeft,end:Alignment.centerRight,colors:const[Color(0xFFEDEDED),Color(0xFFF5F5F5),Color(0xFFEDEDED),],// 动画值 0→1 映射成 -1→1 的平移transform:_SlidingGradientTransform(_ctrl.value*2-1),)两条路怎么选,我的看法是:
| 做法 | 优点 | 什么时候用 |
|---|---|---|
平移stops | 代码短,没有额外类,参数直观 | 简单的横向扫光,大多数骨架屏够用了 |
自定义GradientTransform | 能旋转、能缩放,玩得花 | 需要斜向扫光、或者要复用到复杂渐变上 |
一般而言,做骨架屏我就用前者了,够用就好,没必要太折腾。
四、几个我踩过或者差点踩到的细节
1.dispose里一定要_ctrl.dispose()
这个是真事儿。我第一版忘了写,页面退出去之后AnimationController还在那儿突突地跑,repeat又是无限的,控制台立马给我甩了一条:
_ShimmerBlockState#a1b2c(ticker active) was disposed with an active Ticker.更要命的是列表页反复进出之后,CPU 占用肉眼可见地往上爬。各位看官写带repeat的动画,dispose请当成肌肉记忆。
2. 亮带和底色的差值别拉太大
我最早为了"效果明显",把底色写成0xFFE0E0E0、亮带写成0xFFFFFFFF,结果扫起来跟舞台灯似的,晃眼。后来收到EDEDED/F5F5F5这个程度,反而高级了。骨架屏是用来降低焦虑的,不是用来抢戏的。
3. 骨架屏的形状要"像"真实内容
这条比扫光重要多了。如果真实卡片是「左边一个头像圆块 + 右边两行文字」,你的骨架屏就该摆一个圆 + 两条长短不一的横杠。要是随便糊几个等宽方块上去,内容一加载完,布局哗地一变,那跳动感跟不做骨架屏没啥区别,对吧。
4. 抽成公共组件,别每页手写一份
我一开始是在列表页里直接写的,后来详情页、统计页也要用,复制了三份。改个圆角要改三个地方,扫光速度三个页面还不一样,看着特别业余。后来统一抽成ShimmerBlock,所有骨架屏都拿它拼,风格立马就齐了。
顺手再抽两个组合件会更爽:
/// 一行文字骨架:宽度按百分比给,长短错落更像真内容classShimmerLineextendsStatelessWidget{constShimmerLine({super.key,this.widthFactor=1.0});finaldouble widthFactor;@overrideWidgetbuild(BuildContextcontext){returnFractionallySizedBox(alignment:Alignment.centerLeft,widthFactor:widthFactor,child:constShimmerBlock(height:14,radius:4),);}}5. 一屏的骨架块数量控制一下
骨架屏的意义是"占位",不是"还原"。一屏摆 5~8 个卡片占位就够了,摆 30 个既没人看得见,动画又白跑。我一般直接固定渲染 6 条,简单省事。
为了不让各位看官看完散掉,我把上面五条收成一张自检清单:
| 自检项 | 该怎么做 | 反例 / 坑 |
|---|---|---|
dispose释放 | _ctrl.dispose()写进dispose | 忘了 → 控制台报 active Ticker,CPU 爬升 |
| 颜色对比 | 底色/亮带差值小(EDEDED / F5F5F5) | 差值拉满 → 舞台灯既视感,晃眼 |
| 形状贴近 | 按真实内容摆圆/横杠 | 等宽方块 → 加载完布局哗变,跳动感更强 |
| 抽公共组件 | 统一ShimmerBlock拼装 | 每页复制三份 → 风格不齐、难维护 |
| 块数量 | 一屏 5~8 个,固定 6 条 | 摆 30 个 → 没人看见,动画白跑 |
小结
好啦,骨架屏扫光这点事儿就唠到这。
回头看,这篇最大的收获其实不是那段代码,而是提醒我自己一件事:遇到报错先别急着下"这个 API 被删了"的结论。我当时要是多花两分钟去翻一眼源码或者官方文档,就不用绕这一圈了。当然绕出来的stops平移法确实好用,也算因祸得福哈。
核心就三句话:
- 扫光不一定非得靠
transform,平移stops一样能做,而且更好控。 repeat(reverse: true)+AnimatedBuilder,平滑且省性能,dispose别忘。- 骨架屏抽成公共
ShimmerBlock,形状贴近真实内容,颜色对比别太冲。
如果这篇小文帮各位看官省下了一个下午,希望您用发财的小手点个小赞哈!那么各位看官,您做骨架屏是自己手撸还是直接上shimmer这类现成的包呢?欢迎在评论区聊聊,我也想看看有没有更省事的路子。谢谢大家!
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!
相关阅读
- Flutter 401 自动刷新拦截器并发死锁:_refreshQueue 死锁根治实录
- Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战
- Flutter Riverpod 在 build 期改 provider 导致整页崩溃,踩坑实录
- Flutter Debug 红屏、Release 灰屏:你的 release-only bug,只是异常被藏起来了
- Flutter 可复用公共组件库设计与落地:AppDialog/BottomSheet 等实战