티스토리 뷰
Flutter로 다운로드 버튼을 구현하는 방법에 대해 알아보겠습니다. 이 버튼은 다운로드 상태에 따라 시각적으로 변화하며, 사용자가 다운로드 과정 중 어떤 단계에 있는지 쉽게 파악할 수 있도록 도와줍니다. 이번 포스트에서는 Flutter 문서를 기반으로, 다운로드 버튼을 단계별로 구현하는 방법을 상세히 설명하고, 중요한 기능과 효과적인 활용 방안을 제시합니다.
다운로드 버튼의 상태 이해하기
다운로드 버튼은 4가지 주요 상태를 가집니다. 이 상태들은 각각의 다운로드 단계에 맞게 시각적 변화를 제공합니다.
- 다운로드되지 않음: 다운로드가 시작되지 않은 초기 상태입니다.
- 다운로드 중 준비 중: 다운로드가 시작되며, 데이터를 수집하고 있는 중입니다.
- 다운로드 중: 다운로드가 진행 중이고, 진행 상황을 보여줍니다.
- 다운로드 완료: 다운로드가 완료된 상태로, 사용자가 콘텐츠를 열 수 있습니다.
이 상태들은 enum
으로 정의된 DownloadStatus
를 사용하여 관리됩니다.
enum DownloadStatus {
notDownloaded,
fetchingDownload,
downloading,
downloaded,
}
애니메이션을 통한 버튼 상태 전환
버튼의 상태 변화에 따라 모양과 동작이 달라집니다. 이 변화는 Flutter의 AnimatedContainer를 사용해 매끄럽게 처리할 수 있습니다. 각 상태에 맞는 모양(둥근 사각형 또는 원)을 애니메이션으로 전환하여 시각적으로 변화를 줍니다.
상태에 따른 버튼 모양 변화
notDownloaded
와 downloaded
상태에서는 버튼이 둥근 사각형으로 나타납니다. 반면, fetchingDownload
와 downloading
상태에서는 버튼이 투명한 원형으로 변형됩니다. 이 전환은 ShapeDecoration
위젯을 사용하여 구현합니다.
if (isDownloading || isFetching) {
shape = ShapeDecoration(
shape: const CircleBorder(),
color: Colors.white.withOpacity(0),
);
}
이러한 애니메이션 효과는 사용자에게 상태 전환을 명확하게 전달하는 데 큰 도움을 줍니다.
텍스트와 진행 표시기 추가
버튼은 상태에 따라 텍스트도 변경됩니다. 초기 상태에서는 GET이라는 텍스트가 표시되고, 다운로드가 완료되면 OPEN으로 바뀝니다. 텍스트는 AnimatedOpacity를 사용해 애니메이션과 함께 자연스럽게 전환됩니다.
child: Text(
isDownloaded ? 'OPEN' : 'GET',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: CupertinoColors.activeBlue,
),
),
다운로드 중일 때는 텍스트 대신 진행 상태를 나타내는 로딩 스피너가 나타납니다. 또한, 다운로드 중간에 취소 버튼을 제공하여 사용자가 진행 중인 다운로드를 취소할 수 있게 합니다.
child: ProgressIndicatorWidget(
downloadProgress: downloadProgress,
isDownloading: _isDownloading,
isFetching: _isFetching,
),
버튼 동작 설정: 콜백 처리
다운로드 버튼의 핵심 동작 중 하나는 사용자가 버튼을 클릭했을 때의 반응입니다. 이를 위해 세 가지 주요 콜백을 정의하여 다운로드의 시작, 취소, 완료 후 파일 열기 등의 작업을 처리합니다.
- onDownload: 다운로드를 시작하는 콜백.
- onCancel: 진행 중인 다운로드를 취소하는 콜백.
- onOpen: 다운로드 완료 후 파일을 여는 콜백.
이 콜백들은 DownloadButton
에 전달되어 상태에 따라 적절히 처리됩니다.
void _onPressed() {
switch (status) {
case DownloadStatus.notDownloaded:
onDownload();
break;
case DownloadStatus.downloading:
onCancel();
break;
case DownloadStatus.downloaded:
onOpen();
break;
default:
break;
}
}
Flutter에서의 다운로드 버튼 설계: 효과적인 이유
이 다운로드 버튼은 여러 면에서 효과적인 설계로 볼 수 있습니다.
- 명확한 시각적 피드백: 다운로드 상태에 따른 텍스트와 애니메이션은 사용자에게 직관적으로 상태를 전달합니다.
- 매끄러운 전환: 애니메이션을 사용한 상태 전환은 사용자 경험을 향상시키며, 앱의 완성도를 높입니다.
- 사용자 제어 기능: 다운로드 시작, 취소, 완료 후 열기 등의 옵션을 제공함으로써 사용자가 과정을 쉽게 제어할 수 있게 합니다.
결론: Flutter로 상호작용 버튼 쉽게 구현하기
Flutter의 강력한 애니메이션 위젯과 상태 관리 기능을 활용하면 다운로드 버튼과 같은 상호작용 UI를 쉽게 구현할 수 있습니다. 각 상태에 따른 시각적 전환과 사용자 친화적인 인터페이스는 앱의 완성도를 높이고, 사용자 만족도를 극대화합니다. 다운로드 버튼을 활용하여 앱의 효율성을 높이고, 사용자 경험을 한층 향상시켜 보세요.
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) {
var shape = const ShapeDecoration(
shape: StadiumBorder(),
color: CupertinoColors.lightBackgroundGray,
);
if (isDownloading || isFetching) {
shape = ShapeDecoration(
shape: const CircleBorder(),
color: Colors.white.withOpacity(0),
);
}
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.white.withOpacity(0),
valueColor: AlwaysStoppedAnimation(isFetching
? CupertinoColors.lightBackgroundGray
: CupertinoColors.activeBlue),
strokeWidth: 2,
value: isFetching ? null : progress,
);
},
),
);
}
}
'Flutter Cookbook' 카테고리의 다른 글
Flutter로 사진 필터 캐러셀 구현하기: 단계별 가이드 (1) | 2024.10.20 |
---|---|
Flutter 중첩 내비게이션(Nested Navigation) 구현: 완벽한 가이드 (3) | 2024.10.19 |
탭(Tab) 만들기: 쉽고 효율적인 사용자 인터페이스 구현 (0) | 2024.10.17 |
테마 사용 가이드: 앱의 색상과 글꼴 스타일 공유하기 (1) | 2024.10.16 |
커스텀 폰트 사용법 완벽 가이드 (2) | 2024.10.15 |