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参数的,类型是GradientTransformGradientRotation也确实还在。我那个报错,八成是当时把它写到了不接受这个参数的地方(比如误挂在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要 clampLinearGradient要求stops单调不减、且落在[0, 1]区间内。平移之后必然会有值跑出界(比如1.0 + 0.3),不夹一下就要出问题。夹完之后三个点依然是递增的,所以视觉上没毛病。
  • 为什么用AnimatedBuilder而不是setStateAnimatedBuilder只重建 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平移法确实好用,也算因祸得福哈。

核心就三句话:

  1. 扫光不一定非得靠transform平移stops一样能做,而且更好控。
  2. repeat(reverse: true)+AnimatedBuilder,平滑且省性能,dispose别忘。
  3. 骨架屏抽成公共ShimmerBlock,形状贴近真实内容,颜色对比别太冲。

如果这篇小文帮各位看官省下了一个下午,希望您用发财的小手点个小赞哈!那么各位看官,您做骨架屏是自己手撸还是直接上shimmer这类现成的包呢?欢迎在评论区聊聊,我也想看看有没有更省事的路子。谢谢大家!


本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关阅读

  • Flutter 401 自动刷新拦截器并发死锁:_refreshQueue 死锁根治实录
  • Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战
  • Flutter Riverpod 在 build 期改 provider 导致整页崩溃,踩坑实录
  • Flutter Debug 红屏、Release 灰屏:你的 release-only bug,只是异常被藏起来了
  • Flutter 可复用公共组件库设计与落地:AppDialog/BottomSheet 等实战