建立一個下載按鈕
應用程式中充滿了執行長時間執行行為的按鈕。例如,一個按鈕可能會觸發一次下載,啟動一個下載程序,隨著時間的推移接收資料,然後提供對已下載資源的訪問。向用戶顯示長時間執行進度的狀態很有幫助,而按鈕本身是提供此反饋的好地方。在本食譜中,您將構建一個下載按鈕,該按鈕根據應用程式下載的狀態在多個視覺狀態之間過渡。
以下動畫展示了應用程式的行為

定義一個新的無狀態小部件
#您的按鈕小部件需要隨時間改變其外觀。因此,您需要使用自定義無狀態小部件來實現您的按鈕。
定義一個名為 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,
);
},
),
);
}
}