티스토리 뷰

Flutter로 다운로드 버튼을 구현하는 방법에 대해 알아보겠습니다. 이 버튼은 다운로드 상태에 따라 시각적으로 변화하며, 사용자가 다운로드 과정 중 어떤 단계에 있는지 쉽게 파악할 수 있도록 도와줍니다. 이번 포스트에서는 Flutter 문서를 기반으로, 다운로드 버튼을 단계별로 구현하는 방법을 상세히 설명하고, 중요한 기능과 효과적인 활용 방안을 제시합니다.

다운로드 버튼의 상태 이해하기

다운로드 버튼은 4가지 주요 상태를 가집니다. 이 상태들은 각각의 다운로드 단계에 맞게 시각적 변화를 제공합니다.

  1. 다운로드되지 않음: 다운로드가 시작되지 않은 초기 상태입니다.
  2. 다운로드 중 준비 중: 다운로드가 시작되며, 데이터를 수집하고 있는 중입니다.
  3. 다운로드 중: 다운로드가 진행 중이고, 진행 상황을 보여줍니다.
  4. 다운로드 완료: 다운로드가 완료된 상태로, 사용자가 콘텐츠를 열 수 있습니다.

이 상태들은 enum으로 정의된 DownloadStatus를 사용하여 관리됩니다.

enum DownloadStatus {
  notDownloaded,
  fetchingDownload,
  downloading,
  downloaded,
}

애니메이션을 통한 버튼 상태 전환

버튼의 상태 변화에 따라 모양과 동작이 달라집니다. 이 변화는 Flutter의 AnimatedContainer를 사용해 매끄럽게 처리할 수 있습니다. 각 상태에 맞는 모양(둥근 사각형 또는 원)을 애니메이션으로 전환하여 시각적으로 변화를 줍니다.

상태에 따른 버튼 모양 변화

notDownloadeddownloaded 상태에서는 버튼이 둥근 사각형으로 나타납니다. 반면, fetchingDownloaddownloading 상태에서는 버튼이 투명한 원형으로 변형됩니다. 이 전환은 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,
),

버튼 동작 설정: 콜백 처리

다운로드 버튼의 핵심 동작 중 하나는 사용자가 버튼을 클릭했을 때의 반응입니다. 이를 위해 세 가지 주요 콜백을 정의하여 다운로드의 시작, 취소, 완료 후 파일 열기 등의 작업을 처리합니다.

  1. onDownload: 다운로드를 시작하는 콜백.
  2. onCancel: 진행 중인 다운로드를 취소하는 콜백.
  3. 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,
          );
        },
      ),
    );
  }
}