ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter 骨架屏 Shimmer 实现:不用 transform 的扫光法实战

Flutter 骨架屏 Shimmer 实现:不用 transform 的扫光法实战 Flutter 骨架屏 Shimmer 实现不用 transform 的扫光法实战作者FungLeo 适用Flutter 3.x场景列表页想做「骨架屏 扫光」照着老教程写LinearGradient(transform: ...)结果编译直接不过。前言说实话骨架屏这玩意儿现在基本算列表页的标配了。我以前是不太在乎这个的页面加载中就丢个转圈圈CircularProgressIndicator在屏幕正中间能用就行。直到有一次我自己拿真机点了几十遍我们那个列表页越点越烦——每次都是白屏一闪、转圈一秒、内容啪地弹出来跳动感特别强看着就廉价。于是我决定上骨架屏。灰色块块摆好之后效果确实好多了但总觉得少点意思静态的灰块看久了跟卡死了没啥区别。那就再加个扫光吧让灰块上有一道浅色的亮带来回滑一看就知道我在加载我还活着。结果就是这一步把我卡了小半个下午。网上一搜全是LinearGradient(transform: GradientTransform(...))的写法我复制过来IDE 直接给我标红undefined_named_parameter: The named parameter transform isnt 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.height16,this.radius8,});finaldouble?width;// 不传就撑满父级可用宽度finaldouble height;finaldouble radius;overrideStateShimmerBlockcreateState()_ShimmerBlockState();}class_ShimmerBlockStateextendsStateShimmerBlockwithSingleTickerProviderStateMixin{latefinalAnimationController_ctrlAnimationController(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.0shift).clamp(0.0,1.0),(0.5shift).clamp(0.0,1.0),(1.0shift).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 ~ 1overrideMatrix4?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.widthFactor1.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 TickerCPU 爬升颜色对比底色/亮带差值小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 等实战
返回列表