创建闪光加载效果
加载时间在应用程序开发中是不可避免的。 从用户体验 (UX) 的角度来看, 最重要的是向用户显示加载正在进行中。一种流行的方法 来告知用户数据正在加载,就是在 近似于正在加载的内容类型的形状上 显示带有闪光动画的铬色。
以下动画显示了应用程序的行为:
此示例从定义和定位的内容 widget 开始。 右下角还有一个浮动操作按钮 (FAB),可在加载模式和加载完成模式之间切换,以便您可以轻松验证您的实现。
绘制闪光形状
#在此效果中闪光的形状与最终加载的实际内容无关。
因此,目标是尽可能准确地显示代表最终内容的形状。
在内容具有清晰边界的情况下,显示准确的形状很容易。例如,在此示例中,有一些圆形图像和一些圆角矩形图像。您可以绘制与这些图像轮廓精确匹配的形状。
另一方面,考虑出现在圆角矩形图像下方的文本。在文本加载之前,您不知道有多少行文本。 因此,尝试为每一行文本绘制一个矩形毫无意义。相反,在数据加载期间,您绘制几个非常细的圆角矩形来代表将要显示的文本。形状和大小并不完全匹配,但这没关系。
从屏幕顶部的圆形列表项开始。 确保每个 CircleListItem
widget 在加载图像时显示一个带颜色的圆圈。
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.dev/cookbook'
'/img-files/effects/split-check/Avatar1.jpg',
fit: BoxFit.cover,
),
),
),
);
}
}
只要您的 widget 显示某种形状,您就可以在此示例中应用闪光效果。
与 CircleListItem
widget 类似, 确保 CardListItem
widget 在图像将要出现的地方显示颜色。 此外,在 CardListItem
widget 中, 根据当前加载状态在文本显示和矩形之间切换。
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.dev/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
的 widget。顾名思义,ShaderMask
widget 会将其着色器应用于其子 widget,但仅限于子 widget 已绘制内容的区域。例如,您将只将着色器应用于您之前配置的黑色形状。
定义一个铬色线性渐变,将其应用于闪光形状。
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
的新有状态 widget,它使用 ShaderMask
包装给定的 child
widget。配置 ShaderMask
widget 以将闪光渐变作为着色器应用,其 blendMode
为 srcATop
。srcATop
混合模式会用着色器颜色替换 child
widget 绘制的任何颜色。
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
widget 包装您的 CircleListItem
widget。
Widget _buildTopRowItem() {
return ShimmerLoading(
isLoading: _isLoading,
child: const CircleListItem(),
);
}
使用 ShimmerLoading
widget 包装您的 CardListItem
widget。
Widget _buildListItem() {
return ShimmerLoading(
isLoading: _isLoading,
child: CardListItem(
isLoading: _isLoading,
),
);
}
当您的形状正在加载时,它们现在会显示从 shaderCallback
返回的闪光渐变。
这是朝着正确方向迈出的重要一步, 但此渐变显示存在问题。每个 CircleListItem
widget 和每个 CardListItem
widget 都会显示渐变的新版本。对于此示例,整个屏幕应 看起来像一个大的闪光表面。您将在下一步解决此问题。
绘制一个大的闪光
#要在屏幕上绘制一个大的闪光, 每个 ShimmerLoading
widget 都需要 根据该 ShimmerLoading
widget 在屏幕上的位置绘制相同的全屏渐变。
更准确地说,与其假设闪光 应该占据整个屏幕, 应该有一些区域共享闪光。也许该区域占据了整个屏幕, 或者也许没有。在 Flutter 中解决此类问题的办法是定义另一个 widget, 该 widget 位于 widget 树中所有 ShimmerLoading
widget 的上方,并将其称为 Shimmer
。然后,每个 ShimmerLoading
widget 获取对 Shimmer
祖先的引用,并请求要显示的所需大小和渐变。
定义一个名为 Shimmer
的新有状态 widget,该 widget 接收 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
中查找子级的 position。
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
widget 包装屏幕上的所有内容。
class _ExampleUiLoadingAnimationState extends State<ExampleUiLoadingAnimation> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Shimmer(
linearGradient: _shimmerGradient,
child: ListView(
// ListView Contents
),
),
);
}
}
在您的 ShimmerLoading
widget 中使用 Shimmer
widget 来绘制共享渐变。
class _ShimmerLoadingState extends State<ShimmerLoading> {
@override
Widget build(BuildContext context) {
if (!widget.isLoading) {
return widget.child;
}
// 收集祖先闪光信息。
final shimmer = Shimmer.of(context)!;
if (!shimmer.isSized) {
// 祖先 Shimmer widget 尚未布局
// 完成。返回一个空框。
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
widget 现在显示一个共享渐变,该渐变占据了 Shimmer
widget 内的所有空间。
动画化闪光
#闪光渐变需要移动才能 呈现闪闪发光的视觉效果。
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
widget 不会随着渐变的变化而重新绘制自身。因此,看起来什么 也没有发生。
将 _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(() {
// 更新闪光绘制。
});
}
}
}
恭喜! 您现在拥有一个全屏、 动画化的闪光效果,它会在内容加载时启用和禁用。
交互式示例
#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(() {
// 更新闪光绘制。
});
}
}
@override
Widget build(BuildContext context) {
if (!widget.isLoading) {
return widget.child;
}
// 收集祖先闪光信息。
final shimmer = Shimmer.of(context)!;
if (!shimmer.isSized) {
// 祖先 Shimmer widget 尚未布局
// 完成。返回一个空框。
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,
);
}
}
//----------- 列表项 ---------
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.dev/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.dev/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 的最新稳定版本。页面最后更新于 2025-01-30。 查看源代码 或 报告问题。