很久沒有更新過小技巧系列,今天簡單介紹一個非常好用的骨架屏框架 skeletonizer ,它主要是通過將你現有的布局自動簡化為簡單的骨架,并添加動畫效果來實現加載過程,而使用成本則是簡單的添加一個 Skeletonizer
作為 parent :
Skeletonizer(enabled: _loading,child: ListView.builder(itemCount: 7,itemBuilder: (context, index) {return Card(child: ListTile(title: Text('Item number $index as title'),subtitle: const Text('Subtitle here'),trailing: const Icon(Icons.ac_unit),),);},),
)
當然,在實際使用場景中,一般情況在列表返回之前我們是沒有數據的,所以可以在加載過程中,通過 skeletonizer 提供的 BoneMock
來組裝一個你需要長度的數據列表:
final fakeUsers = List.filled(7, User(name: BoneMock.name,jobTitle: BoneMock.words(2),email: BoneMock.email,createdAt: BoneMock.date, ),);final users = _loading ? fakeUsers : realUsers;return Skeletonizer(enabled: _loading,child: UserList(users: users),);
那 skeletonizer 是如何做到這個自動轉換控件為骨架屏的呢?核心就是在繪制 child 時,通過自定義 context 來替換默認 PaintingContext
:
在 skeletonizer 內部,它的
RenderSkeletonizer
是一個RenderProxyBox
實現,作為一個RenderProxyBox
的子類,它在布局階段表現得像一個透明代理,但在繪制階段會接管控制權,決定是繪制真實的子節點還是繪制骨架。
簡單來說,skeletonizer 就是通過自定義 PaintingContext
來攔截處理 child 的渲染 ,這里我們先簡單看看它的核心代碼的作用:
-
render_skeletonizer.dart
:- 它是
RenderObject
的實現,也就是實際負責渲染的對象,RenderSkeletonizer
和RenderSliverSkeletonizer
的核心就是 overridepaint
方法,當Skeletonizer
被激活時,它們不會像平常一樣繪制child
,而是創建一個自定義的SkeletonizerPaintingContext
來接管繪制工作
- 它是
-
skeletonizer_painting_context.dart
:- 骨架屏效果的關鍵,繼承自
PaintingContext
,但是提供了一個自定義的Canvas
對象SkeletonizerCanvas
,這個自定義的Canvas
會攔截所有來自 child 的繪制,然后用骨架的樣式來替代它們
- 骨架屏效果的關鍵,繼承自
-
uniting_painting_context.dart
:- 在 paint 里對應
Skeleton.unite
的特殊實現,它提供了一個名為UnitingCanvas
的特殊Canvas
,當child
在這個Canvas
上繪制時,它不會真的去繪制每個元素,而是計算所有繪制操作的區域,并將它們合并成一個大的矩形(unitedRect
),最終這個合并后的大矩形會被統一渲染成一個骨架塊
- 在 paint 里對應
-
/effects/\*.dart
:- 這個目錄主要用于定義骨架屏的視覺動畫效果,其中
painting_effect.dart
定義了所有效果必須遵守的抽象基類PaintingEffect
,主要是通過構建Paint
來構建動畫,默認的對應實現有:shimmer_effect.dart
: 實現了最常見的“微光”或“閃爍”效果,通過一個滑動的LinearGradient
(線性漸變) 來實現pulse_effect.dart
: 實現了“脈沖”效果,在兩種顏色之間來回漸變sold_color_effect.dart
: 純色效果,沒有動畫
- 這個目錄主要用于定義骨架屏的視覺動畫效果,其中
所以,整個骨架屏的渲染流程如上圖所示,可以總結為:
-
啟用 Skeletonizer:
- 當
Skeletonizer(enabled: true, child: ...)
被構建時,它會啟動一個動畫控制器(AnimationController
),并根據配置選擇一個PaintingEffect
(例如ShimmerEffect
)
- 當
-
創建 RenderObject:
Skeletonizer
會創建一個RenderSkeletonizer
(或RenderSliverSkeletonizer
) 對象,這個RenderObject
會將自己標記為isRepaintBoundary = true
,這意味著它會創建一個獨立的繪制層 (Layer)
-
接管繪制上下文:
- 在
paint
階段,RenderSkeletonizer
不會像普通RenderObject
那樣直接調用super.paint
來繪制child
,相反它會創建一個SkeletonizerPaintingContext
實例,用于攔截繪制
- 在
-
攔截繪制指令:
SkeletonizerPaintingContext
內部包含一個SkeletonizerCanvas
,當 Flutter 引擎嘗試繪制child
時(比如Text
、Container
、Icon
等),所有對canvas
的操作(如drawParagraph
,drawRect
,drawImage
)都會被SkeletonizerCanvas
攔截
-
替換為骨架樣式:
SkeletonizerCanvas
會根據攔截到的繪制指令的類型和位置,繪制出相應的骨架形態,并實現一些系列繪制方法,比如:- 文本 (
drawParagraph
): 它會計算出文本的每一行在哪里,然后用一系列矩形來代替真實的文字,矩形的圓角、是否對齊等: - 矩形/圓角矩形 (
drawRect
/drawRRect
): 它會檢查這個矩形是否被標記為“葉子節點”(比如一個沒有子節點的Container
或被Skeleton.leaf
包裹的 Widget),如果是,它就會使用從PaintingEffect
(如ShimmerEffect
) 創建的shaderPaint
(帶有閃爍效果的畫筆) 來填充這個區域,如果不是,它可能會根據配置繪制一個純色背景,或者干脆忽略它: - ······
- 文本 (
-
應用動畫效果:
- 所有用于繪制骨架的
shaderPaint
都來自于當前的PaintingEffect
,Skeletonizer
的AnimationController
會不斷更新動畫值 (animationValue
),PaintingEffect
根據這個值來創建每一幀的Paint
對象,對于ShimmerEffect
來說,這就表現為一個不斷移動的漸變,從而產生了微光流動的效果:
- 所有用于繪制骨架的
而在使用使用中,skeletonizer 也提供了豐富的可配置細節,例如:
-
skeleton.dart
: 提供了一系列控制場景:-
Skeleton.ignore
: 忽略某個子 Widget,不對其進行骨架化Card(child: ListTile(title: Text('The title goes here'),subtitle: Text('Subtitle here'),trailing: Skeleton.ignore( // the icon will not be skeletonizedchild: Icon(Icons.ac_unit, size: 40),),), )
-
Skeleton.leaf
: 容器標記為葉子控件,直接還用 shader paint 繪制Skeleton.leaf(child : Card(child: ListTile(title: Text('The title goes here'),subtitle: Text('Subtitle here'),trailing: Icon(Icons.ac_unit, size: 40),),) )
-
Skeleton.keep
: 在骨架化時,保持某個子 Widget 的原始樣貌Card(child: ListTile(title: Text('The title goes here'),subtitle: Text('Subtitle here'),trailing: Skeleton.keep( // the icon will be painted as ischild: Icon(Icons.ac_unit, size: 40),),), )
-
Skeleton.replace
: 在骨架化時,用一個替代的 Widget (比如一個簡單的灰色方塊) 來顯示,比如遇到需要Image
空間的場景Card(child: ListTile(title: Text('The title goes here'),subtitle: Text('Subtitle here'),trailing: Skeleton.replace( // the icon will be replaced when skeletonizer is enabledwidth: 50, // the width of the replacementheight: 50, // the height of the replacementreplacement: // defaults to a DecoratedBoxchild: Icon(Icons.ac_unit, size: 40),),),);
-
Skeleton.unite
: 將多個子 Widget 合并成一個大的骨架塊Card(child: ListTile(title: Text('Item number 1 as title'),subtitle: Text('Subtitle here'),trailing: Skeleton.unite(child: Row(mainAxisSize: MainAxisSize.min,children: [Icon(Icons.ac_unit, size: 32),SizedBox(width: 8),Icon(Icons.access_alarm, size: 32),],),),), )
作用 場景 Skeleton.ignore
完全跳過骨架化 在加載時也需原樣顯示的 Logo 或品牌元素 Skeleton.leaf
將容器標記為終端骨骼 將一個 Card
組件顯示為一整個實心骨架塊Skeleton.keep
保持自身,骨架化子孫 保持一個帶特殊邊框的容器,但骨架化其內部的文本和圖標 Skeleton.shade
為自定義繪制應用效果 骨架化一個使用 CustomPainter
繪制的圖表或圖形Skeleton.replace
在骨架化時替換組件 處理 Image.network
,用一個占位方塊替換加載中的網絡圖片Skeleton.unite
將多個骨骼合并為一個 將一行緊鄰的多個 Icon
合并成一個連續的長條形骨架Skeleton.ignorePointers
禁用指針事件 防止用戶點擊處于加載狀態的按鈕或列表項 -
-
bone.dart
: 支持通過Skeletonizer.zone
場景,手動自定義提供了一系列預設的“骨骼”Widget,用于手動搭建骨架屏布局,支持:Bone.text()
Bone.multiText()
Bone.circle()
Bone.square()
Bone.icon()
Bone.button()
Bone.iconButton()
Skeletonizer.zone(child: Card(child: ListTile(leading: Bone.circle(size: 48), title: Bone.text(words: 2),subtitle: Bone.text(),trailing: Bone.icon(), ),),);
-
effects/*.dart
, 主要用于定義了骨架屏的視覺動畫效果,其中painting_effect.dart
定義了抽象基類PaintingEffect
:-
shimmer_effect.dart
: 實現了最常見的“微光”或“閃爍”效果,通過一個滑動的LinearGradient
(線性漸變) 來實現 -
pulse_effect.dart
: 實現了“脈沖”效果,在兩種顏色之間來回漸變 -
sold_color_effect.dart
: 純色效果,沒有動畫
-
當然,在一些復雜嵌套場景,或者某些特殊控件,比如 SwitchListTile
,還有比如 RoundedSuperellipseBorder
這樣的自定義邊框形狀 等,框架在便利和處理時會無法處理對應的狀態或者復現形狀,這也算是它的局限性。
但是瑕不掩瑜,除了需要處理的 fake 數據部分,整體使用還是相當便捷,skeletonizer 的自動化能力可以極大地減少樣板代碼,并保證 UI 占位的一致性,這也是它值的推薦的原因。
那么,你會在你的應用里使用骨架屏嗎?
參考鏈接
- https://github.com/Milad-Akarie/skeletonizer