建立微光載入效果
在應用程式開發中,載入時間是不可避免的。從使用者體驗(UX)的角度來看,最重要的事情是向用戶表明正在進行載入。一種向用戶傳達資料正在載入的常用方法是,在近似載入內容型別的形狀上顯示一種帶有閃光動畫的鉻色。
以下動畫展示了應用程式的行為

本指南首先定義並定位了內容小部件。在右下角還有一個浮動操作按鈕(FAB),它在載入模式和已載入模式之間切換,以便您可以輕鬆驗證您的實現。
繪製閃光形狀
#此效果中的閃光形狀獨立於最終載入的實際內容。
因此,目標是儘可能準確地顯示代表最終內容的形狀。
在內容具有清晰邊界的情況下,顯示準確的形狀很容易。例如,在本指南中,有一些圓形影像和一些圓角矩形影像。您可以繪製精確匹配這些影像輪廓的形狀。
另一方面,請考慮圓角矩形影像下方出現的文字。在文字載入之前,您不知道會有多少行文字。因此,試圖為每一行文字繪製一個矩形是沒有意義的。相反,在資料載入時,您繪製幾個非常細的圓角矩形來表示將要出現的文字。形狀和大小不完全匹配,但沒關係。
從螢幕頂部的圓形列表項開始。確保每個 CircleListItem 小部件在影像載入時顯示一個帶有顏色的圓。
class CircleListItem extends StatelessWidget {
const CircleListItem({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Container(
width: 54,
height: 54,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: ClipOval(
child: Image.network(
'https://docs.flutter.club.tw/cookbook'
'/img-files/effects/split-check/Avatar1.jpg',
fit: BoxFit.cover,
),
),
),
);
}
}只要您的小部件顯示某種形狀,您就可以應用此指南中的閃光效果。
與 CircleListItem 小部件類似,請確保 CardListItem 小部件在影像將要顯示的位置顯示一個顏色。此外,在 CardListItem 小部件中,根據當前載入狀態在文字和矩形的顯示之間切換。
class CardListItem extends StatelessWidget {
const CardListItem({super.key, required this.isLoading});
final bool isLoading;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildImage(), const SizedBox(height: 16), _buildText()],
),
);
}
Widget _buildImage() {
return AspectRatio(
aspectRatio: 16 / 9,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
'https://docs.flutter.club.tw/cookbook'
'/img-files/effects/split-check/Food1.jpg',
fit: BoxFit.cover,
),
),
),
);
}
Widget _buildText() {
if (isLoading) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 24,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 16),
Container(
width: 250,
height: 24,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
),
],
);
} else {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do '
'eiusmod tempor incididunt ut labore et dolore magna aliqua.',
),
);
}
}
}您的 UI 現在會根據是載入中還是已載入而有所不同。透過暫時註釋掉影像 URL,您可以看到 UI 的兩種渲染方式。

下一個目標是用一種看起來像閃光的漸變來繪製所有彩色區域。
繪製閃光漸變
#本指南中實現效果的關鍵是使用一個名為 ShaderMask 的小部件。ShaderMask 小部件顧名思義,會將一個著色器應用於其子項,但僅限於子項已經繪製了某些內容的區域。例如,您將僅將著色器應用於您之前配置的黑色形狀。
定義一個鉻色線性漸變,該漸變將應用於閃光形狀。
const _shimmerGradient = LinearGradient(
colors: [Color(0xFFEBEBF4), Color(0xFFF4F4F4), Color(0xFFEBEBF4)],
stops: [0.1, 0.3, 0.4],
begin: Alignment(-1.0, -0.3),
end: Alignment(1.0, 0.3),
tileMode: TileMode.clamp,
);定義一個新的有狀態小部件 ShimmerLoading,它用 ShaderMask 包裝給定的 child 小部件。將 ShaderMask 小部件配置為將閃光漸變作為著色器應用,並將 blendMode 設定為 srcATop。srcATop 混合模式會用著色器顏色替換您 child 小部件繪製的任何顏色。
class ShimmerLoading extends StatefulWidget {
const ShimmerLoading({
super.key,
required this.isLoading,
required this.child,
});
final bool isLoading;
final Widget child;
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading> {
@override
Widget build(BuildContext context) {
if (!widget.isLoading) {
return widget.child;
}
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) {
return _shimmerGradient.createShader(bounds);
},
child: widget.child,
);
}
}將您的 CircleListItem 小部件包裝在 ShimmerLoading 小部件中。
Widget _buildTopRowItem() {
return ShimmerLoading(isLoading: _isLoading, child: const CircleListItem());
}將您的 CardListItem 小部件包裝在 ShimmerLoading 小部件中。
Widget _buildListItem() {
return ShimmerLoading(
isLoading: _isLoading,
child: CardListItem(isLoading: _isLoading),
);
}當您的形狀正在載入時,它們現在顯示從 shaderCallback 返回的閃光漸變。
這是朝著正確方向邁出的一大步,但這種漸變顯示存在一個問題。每個 CircleListItem 小部件和每個 CardListItem 小部件都會顯示一個新版本的漸變。對於本指南,整個螢幕應該看起來像一個大的閃光表面。您將在下一步解決此問題。
繪製一個大的閃光
#要繪製一個大的閃光覆蓋整個螢幕,每個 ShimmerLoading 小部件都需要根據該 ShimmerLoading 小部件在螢幕上的位置繪製相同的全屏漸變。
更準確地說,與其假設閃光應該佔據整個螢幕,不如應該有一個共享閃光的區域。也許該區域佔據整個螢幕,或者不佔據。在 Flutter 中解決此類問題的最佳方法是定義另一個小部件,它位於小部件樹中所有 ShimmerLoading 小部件的上方,並將其命名為 Shimmer。然後,每個 ShimmerLoading 小部件都會獲得對 Shimmer 父級元件的引用,並請求要顯示的所需大小和漸變。
定義一個名為 Shimmer 的新有狀態小部件,它接受一個 LinearGradient 並向後代提供對其 State 物件的訪問許可權。
class Shimmer extends StatefulWidget {
static ShimmerState? of(BuildContext context) {
return context.findAncestorStateOfType<ShimmerState>();
}
const Shimmer({super.key, required this.linearGradient, this.child});
final LinearGradient linearGradient;
final Widget? child;
@override
ShimmerState createState() => ShimmerState();
}
class ShimmerState extends State<Shimmer> {
@override
Widget build(BuildContext context) {
return widget.child ?? const SizedBox();
}
}向 ShimmerState 類新增方法,以便訪問 linearGradient、ShimmerState 的 RenderBox 的大小,以及查詢後代在 ShimmerState 的 RenderBox 中的位置。
class ShimmerState extends State<Shimmer> {
Gradient get gradient => LinearGradient(
colors: widget.linearGradient.colors,
stops: widget.linearGradient.stops,
begin: widget.linearGradient.begin,
end: widget.linearGradient.end,
);
bool get isSized =>
(context.findRenderObject() as RenderBox?)?.hasSize ?? false;
Size get size => (context.findRenderObject() as RenderBox).size;
Offset getDescendantOffset({
required RenderBox descendant,
Offset offset = Offset.zero,
}) {
final shimmerBox = context.findRenderObject() as RenderBox;
return descendant.localToGlobal(offset, ancestor: shimmerBox);
}
@override
Widget build(BuildContext context) {
return widget.child ?? const SizedBox();
}
}將您螢幕上的所有內容都包裝在 Shimmer 小部件中。
class _ExampleUiLoadingAnimationState extends State<ExampleUiLoadingAnimation> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Shimmer(
linearGradient: _shimmerGradient,
child: ListView(
// ListView Contents
),
),
);
}
}在您的 ShimmerLoading 小部件中使用 Shimmer 小部件來繪製共享漸變。
class _ShimmerLoadingState extends State<ShimmerLoading> {
@override
Widget build(BuildContext context) {
if (!widget.isLoading) {
return widget.child;
}
// Collect ancestor shimmer information.
final shimmer = Shimmer.of(context)!;
if (!shimmer.isSized) {
// The ancestor Shimmer widget isn't laid
// out yet. Return an empty box.
return const SizedBox();
}
final shimmerSize = shimmer.size;
final gradient = shimmer.gradient;
final offsetWithinShimmer = shimmer.getDescendantOffset(
descendant: context.findRenderObject() as RenderBox,
);
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) {
return gradient.createShader(
Rect.fromLTWH(
-offsetWithinShimmer.dx,
-offsetWithinShimmer.dy,
shimmerSize.width,
shimmerSize.height,
),
);
},
child: widget.child,
);
}
}您的 ShimmerLoading 小部件現在顯示一個共享漸變,該漸變佔據 Shimmer 小部件內的所有空間。
動畫閃光
#閃光漸變需要移動,以產生閃光的效果。
LinearGradient 有一個名為 transform 的屬性,可用於轉換漸變的外觀,例如使其水平移動。transform 屬性接受一個 GradientTransform 例項。
定義一個名為 _SlidingGradientTransform 的類,它實現 GradientTransform 來實現水平滑動效果。
class _SlidingGradientTransform extends GradientTransform {
const _SlidingGradientTransform({required this.slidePercent});
final double slidePercent;
@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}漸變滑動百分比會隨時間變化,以產生運動效果。要更改百分比,請在 ShimmerState 類中配置一個 AnimationController。
class ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
@override
void initState() {
super.initState();
_shimmerController = AnimationController.unbounded(vsync: this)
..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
}透過使用 _shimmerController 的 value 作為 slidePercent,將 _SlidingGradientTransform 應用於 gradient。
LinearGradient get gradient => LinearGradient(
colors: widget.linearGradient.colors,
stops: widget.linearGradient.stops,
begin: widget.linearGradient.begin,
end: widget.linearGradient.end,
transform: _SlidingGradientTransform(
slidePercent: _shimmerController.value,
),
);漸變現在會動畫,但您的各個 ShimmerLoading 小部件不會隨著漸變的改變而重繪。因此,看起來什麼都沒發生。
將 _shimmerController 作為 Listenable 從 ShimmerState 中公開。
Listenable get shimmerChanges => _shimmerController;在 ShimmerLoading 中,監聽對父級 ShimmerState 的 shimmerChanges 屬性的更改,並重繪閃光漸變。
class _ShimmerLoadingState extends State<ShimmerLoading> {
Listenable? _shimmerChanges;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_shimmerChanges != null) {
_shimmerChanges!.removeListener(_onShimmerChange);
}
_shimmerChanges = Shimmer.of(context)?.shimmerChanges;
if (_shimmerChanges != null) {
_shimmerChanges!.addListener(_onShimmerChange);
}
}
@override
void dispose() {
_shimmerChanges?.removeListener(_onShimmerChange);
super.dispose();
}
void _onShimmerChange() {
if (widget.isLoading) {
setState(() {
// Update the shimmer painting.
});
}
}
}恭喜!您現在擁有了一個全屏、動畫閃光效果,該效果會隨著內容的載入而開啟和關閉。
互動示例
#import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleUiLoadingAnimation(),
debugShowCheckedModeBanner: false,
),
);
}
const _shimmerGradient = LinearGradient(
colors: [Color(0xFFEBEBF4), Color(0xFFF4F4F4), Color(0xFFEBEBF4)],
stops: [0.1, 0.3, 0.4],
begin: Alignment(-1.0, -0.3),
end: Alignment(1.0, 0.3),
tileMode: TileMode.clamp,
);
class ExampleUiLoadingAnimation extends StatefulWidget {
const ExampleUiLoadingAnimation({super.key});
@override
State<ExampleUiLoadingAnimation> createState() =>
_ExampleUiLoadingAnimationState();
}
class _ExampleUiLoadingAnimationState extends State<ExampleUiLoadingAnimation> {
bool _isLoading = true;
void _toggleLoading() {
setState(() {
_isLoading = !_isLoading;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Shimmer(
linearGradient: _shimmerGradient,
child: ListView(
physics: _isLoading ? const NeverScrollableScrollPhysics() : null,
children: [
const SizedBox(height: 16),
_buildTopRowList(),
const SizedBox(height: 16),
_buildListItem(),
_buildListItem(),
_buildListItem(),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _toggleLoading,
child: Icon(_isLoading ? Icons.hourglass_full : Icons.hourglass_bottom),
),
);
}
Widget _buildTopRowList() {
return SizedBox(
height: 72,
child: ListView(
physics: _isLoading ? const NeverScrollableScrollPhysics() : null,
scrollDirection: Axis.horizontal,
shrinkWrap: true,
children: [
const SizedBox(width: 16),
_buildTopRowItem(),
_buildTopRowItem(),
_buildTopRowItem(),
_buildTopRowItem(),
_buildTopRowItem(),
_buildTopRowItem(),
],
),
);
}
Widget _buildTopRowItem() {
return ShimmerLoading(isLoading: _isLoading, child: const CircleListItem());
}
Widget _buildListItem() {
return ShimmerLoading(
isLoading: _isLoading,
child: CardListItem(isLoading: _isLoading),
);
}
}
class Shimmer extends StatefulWidget {
static ShimmerState? of(BuildContext context) {
return context.findAncestorStateOfType<ShimmerState>();
}
const Shimmer({super.key, required this.linearGradient, this.child});
final LinearGradient linearGradient;
final Widget? child;
@override
ShimmerState createState() => ShimmerState();
}
class ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
@override
void initState() {
super.initState();
_shimmerController = AnimationController.unbounded(vsync: this)
..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
LinearGradient get gradient => LinearGradient(
colors: widget.linearGradient.colors,
stops: widget.linearGradient.stops,
begin: widget.linearGradient.begin,
end: widget.linearGradient.end,
transform: _SlidingGradientTransform(
slidePercent: _shimmerController.value,
),
);
bool get isSized =>
(context.findRenderObject() as RenderBox?)?.hasSize ?? false;
Size get size => (context.findRenderObject() as RenderBox).size;
Offset getDescendantOffset({
required RenderBox descendant,
Offset offset = Offset.zero,
}) {
final shimmerBox = context.findRenderObject() as RenderBox?;
return descendant.localToGlobal(offset, ancestor: shimmerBox);
}
Listenable get shimmerChanges => _shimmerController;
@override
Widget build(BuildContext context) {
return widget.child ?? const SizedBox();
}
}
class _SlidingGradientTransform extends GradientTransform {
const _SlidingGradientTransform({required this.slidePercent});
final double slidePercent;
@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}
class ShimmerLoading extends StatefulWidget {
const ShimmerLoading({
super.key,
required this.isLoading,
required this.child,
});
final bool isLoading;
final Widget child;
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading> {
Listenable? _shimmerChanges;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_shimmerChanges != null) {
_shimmerChanges!.removeListener(_onShimmerChange);
}
_shimmerChanges = Shimmer.of(context)?.shimmerChanges;
if (_shimmerChanges != null) {
_shimmerChanges!.addListener(_onShimmerChange);
}
}
@override
void dispose() {
_shimmerChanges?.removeListener(_onShimmerChange);
super.dispose();
}
void _onShimmerChange() {
if (widget.isLoading) {
setState(() {
// Update the shimmer painting.
});
}
}
@override
Widget build(BuildContext context) {
if (!widget.isLoading) {
return widget.child;
}
// Collect ancestor shimmer info.
final shimmer = Shimmer.of(context)!;
if (!shimmer.isSized) {
// The ancestor Shimmer widget has not laid
// itself out yet. Return an empty box.
return const SizedBox();
}
final shimmerSize = shimmer.size;
final gradient = shimmer.gradient;
final offsetWithinShimmer = shimmer.getDescendantOffset(
descendant: context.findRenderObject() as RenderBox,
);
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) {
return gradient.createShader(
Rect.fromLTWH(
-offsetWithinShimmer.dx,
-offsetWithinShimmer.dy,
shimmerSize.width,
shimmerSize.height,
),
);
},
child: widget.child,
);
}
}
//----------- List Items ---------
class CircleListItem extends StatelessWidget {
const CircleListItem({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Container(
width: 54,
height: 54,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: ClipOval(
child: Image.network(
'https://docs.flutter.club.tw/cookbook'
'/img-files/effects/split-check/Avatar1.jpg',
fit: BoxFit.cover,
),
),
),
);
}
}
class CardListItem extends StatelessWidget {
const CardListItem({super.key, required this.isLoading});
final bool isLoading;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildImage(), const SizedBox(height: 16), _buildText()],
),
);
}
Widget _buildImage() {
return AspectRatio(
aspectRatio: 16 / 9,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
'https://docs.flutter.club.tw/cookbook'
'/img-files/effects/split-check/Food1.jpg',
fit: BoxFit.cover,
),
),
),
);
}
Widget _buildText() {
if (isLoading) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 24,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 16),
Container(
width: 250,
height: 24,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
),
],
);
} else {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do '
'eiusmod tempor incididunt ut labore et dolore magna aliqua.',
),
);
}
}
}