Ever noticed how download buttons in real apps change states — from “GET”, to a loading spinner, to progress, and finally “OPEN”?
In this article, we’ll build a reusable Flutter widget that does just that — with clean animations and clear UX.
4 States, 1 Button
Let’s start by defining the download states we need:
enum DownloadStatus {
notDownloaded,
fetchingDownload,
downloading,
downloaded,
}
What We’re Building
A download button that visually responds to what’s happening:
GET
when nothing’s downloaded yet- Spinner when fetching metadata
- Circular progress bar while downloading
OPEN
once the download is complete
And yes, everything animated smoothly.
The DownloadButton Widget
This widget doesn’t manage its own state — all data comes from its parent, which makes it easier to test and reuse.
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:
break;
case DownloadStatus.downloading:
onCancel();
case DownloadStatus.downloaded:
onOpen();
}
} @override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onPressed,
child: Stack(
children: [
ButtonShapeWidget(
isDownloading: _isDownloading,
isDownloaded: _isDownloaded,
isFetching: _isFetching,
transitionDuration: transitionDuration,
),
Positioned.fill(
child: AnimatedOpacity(
duration: transitionDuration,
opacity: _isFetching || _isDownloading ? 1.0 : 0.0,
child: Stack(
alignment: Alignment.center,
children: [
ProgressIndicatorWidget(
downloadProgress: downloadProgress,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
if (_isDownloading)
const Icon(Icons.stop, size: 14, color: CupertinoColors.activeBlue),
],
),
),
),
],
),
);
}
}
Button Shape Changes with State
The button smoothly morphs between shapes: a stadium shape for static states (GET
, OPEN
) and a circle for loading/progress.
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 shape = (isDownloading || isFetching)
? const ShapeDecoration(shape: CircleBorder(), color: Colors.transparent)
: const ShapeDecoration(shape: StadiumBorder(), color: CupertinoColors.lightBackgroundGray); return AnimatedContainer(
duration: transitionDuration,
decoration: shape,
curve: Curves.ease,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: AnimatedOpacity(
duration: transitionDuration,
opacity: (isDownloading || isFetching) ? 0.0 : 1.0,
child: Text(
isDownloaded ? 'OPEN' : 'GET',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: CupertinoColors.activeBlue,
),
),
),
),
);
}
}
Spinner & Progress Indicator
We use different visuals for fetching and downloading. Fetching shows a spinner. Downloading shows circular progress with a stop icon.
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) {
if (isFetching) {
return const CircularProgressIndicator(strokeWidth: 2);
} if (isDownloading) {
return SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
value: downloadProgress,
strokeWidth: 2,
backgroundColor: CupertinoColors.systemGrey4,
valueColor: AlwaysStoppedAnimation(CupertinoColors.activeBlue),
),
);
} return const SizedBox.shrink();
}
}
Full Flow at a Glance
StatusWhat user seesWhat tap doesnotDownloadedGET
buttonStarts downloadfetchingDownload
Spinner (fading in/out)Does nothingdownloading
Circular progress + stop iconCancels downloaddownloadedOPEN
buttonOpens the downloaded item
Why This Matters
Buttons like this are everywhere — app stores, podcast players, file downloaders.
Having one that’s:
- smooth,
- reactive,
- and visually clear
…makes your app feel polished and modern. ✨
What Do You Think?
Try it out in your next app!
Got ideas for improvements or customizations? Drop a comment — would love to see your take on it.
#Flutter #DownloadButton #UX #FlutterTips #UIAnimation #StateDrivenUI
Learn more Build a Smart Download Button in Flutter: GET → OPEN with Smooth Animations