建立下載按鈕
應用程式中充滿了會執行長時間運作行為的按鈕。 例如,一個按鈕可能會觸發下載, 這會啟動下載流程,隨著時間接收資料, 最後提供下載後的資源給使用者。 在長時間運作的過程中,向使用者顯示進度是很有幫助的, 而按鈕本身就是提供這類回饋的好位置。在本教學中, 你將建立一個根據應用程式下載狀態, 在多個視覺狀態間切換的下載按鈕。
下方動畫展示了應用程式的行為:

定義一個新的無狀態元件 (StatelessWidget)
#你的按鈕元件需要隨著時間改變外觀。 因此,你需要使用自訂的無狀態元件來實作你的按鈕。
定義一個名為 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, 建立一個 AnimatedContainer,並使用 ShapeDecoration 來顯示圓角矩形或圓形。
建議將形狀的元件樹定義在獨立的 Stateless 元件中,這樣主 build() 方法可以保持簡潔,方便後續擴充。 與其建立一個回傳元件的函式(如 Widget _buildSomething() {}), 更推薦直接建立一個 StatelessWidget 或 StatefulWidget,這樣效能更佳。更多相關考量可參考 documentation 或 Flutter YouTube channel 上的專屬影片。
目前,AnimatedContainer 的 child 只是個 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會顯示一個圓形轉圈指示器(radial spinner)。這個指示器會從notDownloaded階段淡入,並於fetchingDownload階段淡出。
請實作一個圓形轉圈指示器,讓它位於按鈕形狀的上方,並在適當的時機淡入與淡出。
我們已移除ButtonShapeWidget的建構函式(constructor),以便將重點放在其 build 方法以及我們新增的Stack元件(Widget)。
@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 會將圓形進度指示器(radial progress spinner)替換為 逐漸增長的圓形進度條(radial progress bar)。 DownloadButton 也會顯示一個停止按鈕圖示,讓使用者可以取消正在進行中的下載。
請在 DownloadButton 元件(Widget)中新增一個 progress 屬性, 接著更新進度顯示,讓其在 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還需要最後一個細節,就是 按鈕的行為。當使用者點擊按鈕時,按鈕必須執行相應的動作。
請為啟動下載、取消下載,以及開啟下載這三個動作 新增元件(Widget)屬性來作為回呼函式。
最後,請使用GestureDetector元件(Widget)包裹DownloadButton現有的元件樹, 並將點擊事件轉發給對應的回呼屬性。
@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,表示應用程式已準備好讓使用者開啟下載的資源 (Assets)。
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,
);
},
),
);
}
}