创建一个下载按钮
应用程序中充满了执行长时间运行行为的按钮。例如,一个按钮可能会触发一次下载,启动一个下载进程,随着时间的推移接收数据,然后提供对已下载资源的访问。向用户显示长时间运行进度的状态很有帮助,而按钮本身是提供此反馈的好地方。在本食谱中,您将构建一个下载按钮,该按钮根据应用程序下载的状态在多个视觉状态之间过渡。
以下动画展示了应用程序的行为
定义一个新的无状态小部件
#您的按钮小部件需要随时间改变其外观。因此,您需要使用自定义无状态小部件来实现您的按钮。
定义一个名为 DownloadButton
的新无状态小部件。
@immutable
class DownloadButton extends StatelessWidget {
const DownloadButton({super.key});
@override
Widget build(BuildContext context) {
// TODO:
return const SizedBox();
}
}
定义按钮的可能视觉状态
#下载按钮的视觉呈现基于给定的下载状态。定义下载的可能状态,然后更新 DownloadButton
以接受 DownloadStatus
和 Duration
,以指定按钮在状态之间动画所需的时间。
enum DownloadStatus { notDownloaded, fetchingDownload, downloading, downloaded }
@immutable
class DownloadButton extends StatelessWidget {
const DownloadButton({
super.key,
required this.status,
this.transitionDuration = const Duration(milliseconds: 500),
});
final DownloadStatus status;
final Duration transitionDuration;
@override
Widget build(BuildContext context) {
// TODO: We'll add more to this later.
return const SizedBox();
}
}
显示按钮形状
#下载按钮根据下载状态改变其形状。在 notDownloaded
和 downloaded
状态期间,按钮显示一个灰色圆角矩形。在 fetchingDownload
和 downloading
状态期间,按钮显示一个透明圆。
根据当前的 DownloadStatus
,构建一个带有 ShapeDecoration
的 AnimatedContainer
,该 ShapeDecoration
显示一个圆角矩形或一个圆。
考虑将形状的小部件树定义在一个单独的 Stateless
小部件中,以便主 build()
方法保持简单,从而可以进行后续的添加。不要创建返回小部件的函数,如 Widget _buildSomething() {}
,始终优先创建 StatelessWidget
或 StatefulWidget
,这更具性能。更多关于这方面的考虑可以在 文档 或 Flutter YouTube 频道 的一个专用视频中找到。
目前,AnimatedContainer
的子项只是一个 SizedBox
,因为我们将在另一个步骤中再来处理它。
@immutable
class DownloadButton extends StatelessWidget {
const DownloadButton({
super.key,
required this.status,
this.transitionDuration = const Duration(milliseconds: 500),
});
final DownloadStatus status;
final Duration transitionDuration;
bool get _isDownloading => status == DownloadStatus.downloading;
bool get _isFetching => status == DownloadStatus.fetchingDownload;
bool get _isDownloaded => status == DownloadStatus.downloaded;
@override
Widget build(BuildContext context) {
return ButtonShapeWidget(
transitionDuration: transitionDuration,
isDownloaded: _isDownloaded,
isDownloading: _isDownloading,
isFetching: _isFetching,
);
}
}
@immutable
class ButtonShapeWidget extends StatelessWidget {
const ButtonShapeWidget({
super.key,
required this.isDownloading,
required this.isDownloaded,
required this.isFetching,
required this.transitionDuration,
});
final bool isDownloading;
final bool isDownloaded;
final bool isFetching;
final Duration transitionDuration;
@override
Widget build(BuildContext context) {
final ShapeDecoration shape;
if (isDownloading || isFetching) {
shape = const ShapeDecoration(
shape: CircleBorder(),
color: Colors.transparent,
);
} else {
shape = const ShapeDecoration(
shape: StadiumBorder(),
color: CupertinoColors.lightBackgroundGray,
);
}
return AnimatedContainer(
duration: transitionDuration,
curve: Curves.ease,
width: double.infinity,
decoration: shape,
child: const SizedBox(),
);
}
}
您可能想知道为什么对于一个透明圆需要一个 ShapeDecoration
小部件,因为它实际上是不可见的。不可见圆的目的是为了编排所需的动画。AnimatedContainer
开始时是一个圆角矩形。当 DownloadStatus
更改为 fetchingDownload
时,AnimatedContainer
需要从圆角矩形动画到一个圆,然后在动画进行时淡出。实现此动画的唯一方法是定义圆角矩形的起始形状和圆的结束形状。但是,您不希望最终的圆可见,因此将其设为透明,这将导致一个动画淡出。
显示按钮文本
#DownloadButton
在 notDownloaded
阶段显示 GET
,在 downloaded
阶段显示 OPEN
,而在这两个阶段之间不显示任何文本。
添加小部件以在每个下载阶段显示文本,并在两者之间动画文本的不透明度。将文本小部件树添加为按钮包装器小部件中 AnimatedContainer
的子项。
@immutable
class ButtonShapeWidget extends StatelessWidget {
const ButtonShapeWidget({
super.key,
required this.isDownloading,
required this.isDownloaded,
required this.isFetching,
required this.transitionDuration,
});
final bool isDownloading;
final bool isDownloaded;
final bool isFetching;
final Duration transitionDuration;
@override
Widget build(BuildContext context) {
final ShapeDecoration shape;
if (isDownloading || isFetching) {
shape = const ShapeDecoration(
shape: CircleBorder(),
color: Colors.transparent,
);
} else {
shape = const ShapeDecoration(
shape: StadiumBorder(),
color: CupertinoColors.lightBackgroundGray,
);
}
return AnimatedContainer(
duration: transitionDuration,
curve: Curves.ease,
width: double.infinity,
decoration: shape,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: AnimatedOpacity(
duration: transitionDuration,
opacity: isDownloading || isFetching ? 0.0 : 1.0,
curve: Curves.ease,
child: Text(
isDownloaded ? 'OPEN' : 'GET',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: CupertinoColors.activeBlue,
),
),
),
),
);
}
}
在获取下载时显示加载指示器
#在 fetchingDownload
阶段,DownloadButton
显示一个径向加载指示器。该加载指示器会从 notDownloaded
阶段淡入,并淡出到 fetchingDownload
阶段。
实现一个径向加载指示器,它位于按钮形状之上,并在适当的时候淡入淡出。
为了将重点放在 ButtonShapeWidget
的构建方法和我们添加的 Stack
小部件上,我们已经移除了 ButtonShapeWidget
的构造函数。
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onPressed,
child: Stack(
children: [
ButtonShapeWidget(
transitionDuration: transitionDuration,
isDownloaded: _isDownloaded,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
Positioned.fill(
child: AnimatedOpacity(
duration: transitionDuration,
opacity: _isDownloading || _isFetching ? 1.0 : 0.0,
curve: Curves.ease,
child: ProgressIndicatorWidget(
downloadProgress: downloadProgress,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
),
),
],
),
);
}
在下载时显示进度和停止按钮
#在 fetchingDownload
阶段之后是 downloading
阶段。在 downloading
阶段,DownloadButton
将径向进度加载指示器替换为不断增长的径向进度条。DownloadButton
还显示一个停止按钮图标,以便用户可以取消正在进行的下载。
向 DownloadButton
小部件添加一个进度属性,然后在 downloading
阶段更新进度显示以切换到径向进度条。
接下来,在径向进度条的中心添加一个停止按钮图标。
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onPressed,
child: Stack(
children: [
ButtonShapeWidget(
transitionDuration: transitionDuration,
isDownloaded: _isDownloaded,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
Positioned.fill(
child: AnimatedOpacity(
duration: transitionDuration,
opacity: _isDownloading || _isFetching ? 1.0 : 0.0,
curve: Curves.ease,
child: Stack(
alignment: Alignment.center,
children: [
ProgressIndicatorWidget(
downloadProgress: downloadProgress,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
if (_isDownloading)
const Icon(
Icons.stop,
size: 14.0,
color: CupertinoColors.activeBlue,
),
],
),
),
),
],
),
);
}
添加按钮点击回调
#您的 DownloadButton
所需的最后一个细节是按钮的行为。当用户点击按钮时,按钮必须执行相应的操作。
为启动下载、取消下载和打开下载的回调添加小部件属性。
最后,用 GestureDetector
小部件包装 DownloadButton
现有的 widget tree,并将点击事件转发到相应的回调属性。
@immutable
class DownloadButton extends StatelessWidget {
const DownloadButton({
super.key,
required this.status,
this.downloadProgress = 0,
required this.onDownload,
required this.onCancel,
required this.onOpen,
this.transitionDuration = const Duration(milliseconds: 500),
});
final DownloadStatus status;
final double downloadProgress;
final VoidCallback onDownload;
final VoidCallback onCancel;
final VoidCallback onOpen;
final Duration transitionDuration;
bool get _isDownloading => status == DownloadStatus.downloading;
bool get _isFetching => status == DownloadStatus.fetchingDownload;
bool get _isDownloaded => status == DownloadStatus.downloaded;
void _onPressed() {
switch (status) {
case DownloadStatus.notDownloaded:
onDownload();
case DownloadStatus.fetchingDownload:
// do nothing.
break;
case DownloadStatus.downloading:
onCancel();
case DownloadStatus.downloaded:
onOpen();
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onPressed,
child: const Stack(
children: [
/* ButtonShapeWidget and progress indicator */
],
),
);
}
}
恭喜!您已经拥有了一个按钮,该按钮根据其所处的阶段(未下载、正在获取下载、正在下载和已下载)改变其显示。现在,用户可以点击开始下载,点击取消正在进行的下载,以及点击打开已完成的下载。
互动示例
#运行应用
- 点击 GET 按钮以启动模拟下载。
- 按钮会变成一个进度指示器,以模拟正在进行的下载。
- 当模拟下载完成后,按钮会过渡到 OPEN,以指示应用程序已准备好让用户打开已下载的资源。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleCupertinoDownloadButton(),
debugShowCheckedModeBanner: false,
),
);
}
@immutable
class ExampleCupertinoDownloadButton extends StatefulWidget {
const ExampleCupertinoDownloadButton({super.key});
@override
State<ExampleCupertinoDownloadButton> createState() =>
_ExampleCupertinoDownloadButtonState();
}
class _ExampleCupertinoDownloadButtonState
extends State<ExampleCupertinoDownloadButton> {
late final List<DownloadController> _downloadControllers;
@override
void initState() {
super.initState();
_downloadControllers = List<DownloadController>.generate(
20,
(index) => SimulatedDownloadController(
onOpenDownload: () {
_openDownload(index);
},
),
);
}
void _openDownload(int index) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Open App ${index + 1}')));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Apps')),
body: ListView.separated(
itemCount: _downloadControllers.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: _buildListItem,
),
);
}
Widget _buildListItem(BuildContext context, int index) {
final theme = Theme.of(context);
final downloadController = _downloadControllers[index];
return ListTile(
leading: const DemoAppIcon(),
title: Text(
'App ${index + 1}',
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge,
),
subtitle: Text(
'Lorem ipsum dolor #${index + 1}',
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
trailing: SizedBox(
width: 96,
child: AnimatedBuilder(
animation: downloadController,
builder: (context, child) {
return DownloadButton(
status: downloadController.downloadStatus,
downloadProgress: downloadController.progress,
onDownload: downloadController.startDownload,
onCancel: downloadController.stopDownload,
onOpen: downloadController.openDownload,
);
},
),
),
);
}
}
@immutable
class DemoAppIcon extends StatelessWidget {
const DemoAppIcon({super.key});
@override
Widget build(BuildContext context) {
return const AspectRatio(
aspectRatio: 1,
child: FittedBox(
child: SizedBox(
width: 80,
height: 80,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.red, Colors.blue]),
borderRadius: BorderRadius.all(Radius.circular(20)),
),
child: Center(
child: Icon(Icons.ac_unit, color: Colors.white, size: 40),
),
),
),
),
);
}
}
enum DownloadStatus { notDownloaded, fetchingDownload, downloading, downloaded }
abstract class DownloadController implements ChangeNotifier {
DownloadStatus get downloadStatus;
double get progress;
void startDownload();
void stopDownload();
void openDownload();
}
class SimulatedDownloadController extends DownloadController
with ChangeNotifier {
SimulatedDownloadController({
DownloadStatus downloadStatus = DownloadStatus.notDownloaded,
double progress = 0.0,
required VoidCallback onOpenDownload,
}) : _downloadStatus = downloadStatus,
_progress = progress,
_onOpenDownload = onOpenDownload;
DownloadStatus _downloadStatus;
@override
DownloadStatus get downloadStatus => _downloadStatus;
double _progress;
@override
double get progress => _progress;
final VoidCallback _onOpenDownload;
bool _isDownloading = false;
@override
void startDownload() {
if (downloadStatus == DownloadStatus.notDownloaded) {
_doSimulatedDownload();
}
}
@override
void stopDownload() {
if (_isDownloading) {
_isDownloading = false;
_downloadStatus = DownloadStatus.notDownloaded;
_progress = 0.0;
notifyListeners();
}
}
@override
void openDownload() {
if (downloadStatus == DownloadStatus.downloaded) {
_onOpenDownload();
}
}
Future<void> _doSimulatedDownload() async {
_isDownloading = true;
_downloadStatus = DownloadStatus.fetchingDownload;
notifyListeners();
// Wait a second to simulate fetch time.
await Future<void>.delayed(const Duration(seconds: 1));
// If the user chose to cancel the download, stop the simulation.
if (!_isDownloading) {
return;
}
// Shift to the downloading phase.
_downloadStatus = DownloadStatus.downloading;
notifyListeners();
const downloadProgressStops = [0.0, 0.15, 0.45, 0.8, 1.0];
for (final stop in downloadProgressStops) {
// Wait a second to simulate varying download speeds.
await Future<void>.delayed(const Duration(seconds: 1));
// If the user chose to cancel the download, stop the simulation.
if (!_isDownloading) {
return;
}
// Update the download progress.
_progress = stop;
notifyListeners();
}
// Wait a second to simulate a final delay.
await Future<void>.delayed(const Duration(seconds: 1));
// If the user chose to cancel the download, stop the simulation.
if (!_isDownloading) {
return;
}
// Shift to the downloaded state, completing the simulation.
_downloadStatus = DownloadStatus.downloaded;
_isDownloading = false;
notifyListeners();
}
}
@immutable
class DownloadButton extends StatelessWidget {
const DownloadButton({
super.key,
required this.status,
this.downloadProgress = 0.0,
required this.onDownload,
required this.onCancel,
required this.onOpen,
this.transitionDuration = const Duration(milliseconds: 500),
});
final DownloadStatus status;
final double downloadProgress;
final VoidCallback onDownload;
final VoidCallback onCancel;
final VoidCallback onOpen;
final Duration transitionDuration;
bool get _isDownloading => status == DownloadStatus.downloading;
bool get _isFetching => status == DownloadStatus.fetchingDownload;
bool get _isDownloaded => status == DownloadStatus.downloaded;
void _onPressed() {
switch (status) {
case DownloadStatus.notDownloaded:
onDownload();
case DownloadStatus.fetchingDownload:
// do nothing.
break;
case DownloadStatus.downloading:
onCancel();
case DownloadStatus.downloaded:
onOpen();
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onPressed,
child: Stack(
children: [
ButtonShapeWidget(
transitionDuration: transitionDuration,
isDownloaded: _isDownloaded,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
Positioned.fill(
child: AnimatedOpacity(
duration: transitionDuration,
opacity: _isDownloading || _isFetching ? 1.0 : 0.0,
curve: Curves.ease,
child: Stack(
alignment: Alignment.center,
children: [
ProgressIndicatorWidget(
downloadProgress: downloadProgress,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
if (_isDownloading)
const Icon(
Icons.stop,
size: 14,
color: CupertinoColors.activeBlue,
),
],
),
),
),
],
),
);
}
}
@immutable
class ButtonShapeWidget extends StatelessWidget {
const ButtonShapeWidget({
super.key,
required this.isDownloading,
required this.isDownloaded,
required this.isFetching,
required this.transitionDuration,
});
final bool isDownloading;
final bool isDownloaded;
final bool isFetching;
final Duration transitionDuration;
@override
Widget build(BuildContext context) {
final ShapeDecoration shape;
if (isDownloading || isFetching) {
shape = const ShapeDecoration(
shape: CircleBorder(),
color: Colors.transparent,
);
} else {
shape = const ShapeDecoration(
shape: StadiumBorder(),
color: CupertinoColors.lightBackgroundGray,
);
}
return AnimatedContainer(
duration: transitionDuration,
curve: Curves.ease,
width: double.infinity,
decoration: shape,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: AnimatedOpacity(
duration: transitionDuration,
opacity: isDownloading || isFetching ? 0.0 : 1.0,
curve: Curves.ease,
child: Text(
isDownloaded ? 'OPEN' : 'GET',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: CupertinoColors.activeBlue,
),
),
),
),
);
}
}
@immutable
class ProgressIndicatorWidget extends StatelessWidget {
const ProgressIndicatorWidget({
super.key,
required this.downloadProgress,
required this.isDownloading,
required this.isFetching,
});
final double downloadProgress;
final bool isDownloading;
final bool isFetching;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: downloadProgress),
duration: const Duration(milliseconds: 200),
builder: (context, progress, child) {
return CircularProgressIndicator(
backgroundColor: isDownloading
? CupertinoColors.lightBackgroundGray
: Colors.transparent,
valueColor: AlwaysStoppedAnimation(
isFetching
? CupertinoColors.lightBackgroundGray
: CupertinoColors.activeBlue,
),
strokeWidth: 2,
value: isFetching ? null : progress,
);
},
),
);
}
}