建立微光載入效果
在應用程式開發中,載入時間是不可避免的。從使用者體驗(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://flutter-docs.dev.org.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://flutter-docs.dev.org.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
的新有狀態小工具,它會將給定的 child
小工具用 ShaderMask
包裝起來。設定 ShaderMask
小工具,以 srcATop
的 blendMode
將微光漸層作為著色器套用。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,
);
}
}
用 ShimmerLoading
小工具包裝您的 CircleListItem
小工具。
Widget _buildTopRowItem() {
return ShimmerLoading(
isLoading: _isLoading,
child: const CircleListItem(),
);
}
用 ShimmerLoading
小工具包裝您的 CardListItem
小工具。
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
從 ShimmerState
作為 Listenable
公開。
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://flutter-docs.dev.org.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://flutter-docs.dev.org.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.',
),
);
}
}
}
除非另有說明,否則本網站上的文件反映了 Flutter 的最新穩定版本。此頁面最後更新於 2024-06-26。 檢視原始碼 或 回報問題。