Flutter Cookbook (Effects - download button)

Updated:

Flutter Cookbook

Effects

Create a download button

  • Download 버튼을 생성해서 다운로드 중에는 Progress indicator 를 나타내고 완료되면 오픈 버튼을 사용할 수 있게 만드는 기능입니다

Check point!

  • new stateful widget 생성

  • 버튼의 가능한 visual states 생성

  • button 의 모양 설정

  • button 의 text 설정

  • fetching download 중에 spinner 동작 설정 (progress indicator)

  • 다운로드 중에 progress and stop button 설정

  • button tap callbacks 추가하기

// in main.dart

import 'package:cook_book_practice/download_button.dart';
import 'package:cook_book_practice/download_controller.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

import 'demo_app_icon.dart';

void main() {
  runApp(const MaterialApp(
    home: ExampleCupertinoDownloadButton(),
    debugShowCheckedModeBanner: false,
  ));
}

@immutable
class ExampleCupertinoDownloadButton extends StatefulWidget {
  const ExampleCupertinoDownloadButton({Key? key}) : super(key: key);

  @override
  _ExampleCupertinoDownloadButtonState createState() =>
      _ExampleCupertinoDownloadButtonState();
}

class _ExampleCupertinoDownloadButtonState
    extends State<ExampleCupertinoDownloadButton> {
  // DownloadController widget type 변수 선언
  late final List<DownloadController> _downloadControllers;

  @override
  // initState 설정
  void initState() {
    super.initState();
    // _downloadControllers 의 List 20 개 생성 후, SimulatedDownloadController() download_controller.dart 파일에서 import  후에 openDowload open
    _downloadControllers = List<DownloadController>.generate(
      20,
      (index) => SimulatedDownloadController(onOpenDownload: () {
        _openDownload(index);
      }),
    );
  }

  // Open button 을 누를때 Snackbar 가 표시되게 설정
  void _openDownload(int index) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('Open App ${index + 1}'),
      ),
    );
  }

  //  main 화면 appBar, body 부분 설정
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Apps')),
      body: _buildList(),
    );
  }

  // 리스트 생성 _downloadControllers 길이만큼 생성 하고, meterial design 스타일 Divider() seperatorBuilder 각각 적용, itemBuilder 에 _buildListItem 위젯 연결
  Widget _buildList() {
    return ListView.separated(
      itemCount: _downloadControllers.length,
      separatorBuilder: (context, index) => const Divider(),
      itemBuilder: _buildListItem,
    );
  }

  // _buildListItem 위젯 생성
  Widget _buildListItem(BuildContext context, int index) {
    // theme, downloadController 변수 생성
    final theme = Theme.of(context);
    final downloadController = _downloadControllers[index];

    // return 값으로 ListTile 생성
    return ListTile(
      // leading : demo_app_icon.dart state 연결
      leading: const DemoAppIcon(),
      title: Text(
        'App ${index + 1}',
        // text가 overflow 될때 ... 생성
        overflow: TextOverflow.ellipsis,
        style: theme.textTheme.headline6,
      ),
      subtitle: Text(
        'Lorem ipsum dolor #${index + 1}',
        overflow: TextOverflow.ellipsis,
        style: theme.textTheme.caption,
      ),
      // trailing: title 뒷부분의 버튼 설정
      trailing: SizedBox(
        width: 96.0,
        // AnimatedBuilder 생성 : animation : animated builder 연결 , builder: animation 이 변경될때 마다의 값을 widget 값으로 return
        child: AnimatedBuilder(
          animation: downloadController,
          builder: (context, child) {
            // Download button state 연결
            return DownloadButton(
              status: downloadController.downloadStatus,
              downloadProgress: downloadController.progress,
              onDownload: downloadController.startDownload,
              onCancel: downloadController.stopDownload,
              onOpen: downloadController.openDownload,
            );
          },
        ),
      ),
    );
  }
}
// in download_controller.dart

import 'package:flutter/material.dart';

// enum 으로 DownloadStatus 의 required type 설정
enum DownloadStatus {
  notDownloaded,
  fetchingDownload,
  downloading,
  downloaded,
}

// class 상속 정리
// 1.implement: 다중상속 가능 class, abstract class 필수구현
// 2.extends: 다중 상속 불가능, class 상속 선택구현, abstract 필수 구현
// 3.with: 상속 불가능, 다른 class 에서 기능을 가져 오거나 오버라이드 가능

// abstract class(추상클래스) 는 그대로 인스턴스화할 수 없으며 다른 클래스에서 임플리먼트하여 기능을 완성하는 상속 재료로 사용됩니다.

// 추상 class DownloadController 생성 ChangeNotifier implemets
abstract class DownloadController implements ChangeNotifier {
  // DownloadStatus, progress getter
  DownloadStatus get downloadStatus;
  double get progress;

  // start, stop open Download
  void startDownload();
  void stopDownload();
  void openDownload();
}

// SimulatedDownloadController class 생성
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 override
  DownloadStatus _downloadStatus;
  @override
  DownloadStatus get downloadStatus => _downloadStatus;

  // _downloadStatus override
  double _progress;
  @override
  double get progress => _progress;

  // _onOpenDownload voidCallback , _isDownloading 선언
  final VoidCallback _onOpenDownload;
  bool _isDownloading = false;

  // 다운로드 시작 DownloadStatus 다운로드 상태가 아닐때, _doSimulatedDownload 시작
  @override
  void startDownload() {
    if (downloadStatus == DownloadStatus.notDownloaded) {
      _doSimulatedDownload();
    }
  }

  // 다운로드 중단, _isDownloading 가 false, _downloadStatus 가 DownloadStatus 가 다운로드 상태가 아닐때, _progress 가 0.0 일경우  이경우에 상태가 변경되었기 때문에 notifyListener 를 호출해 Provider 에서 알린다
  @override
  void stopDownload() {
    if (_isDownloading) {
      _isDownloading = false;
      _downloadStatus = DownloadStatus.notDownloaded;
      _progress = 0.0;
      notifyListeners();
    }
  }

  // DownloadStatus 가 downloaded 완료 되었을때  _onOpenDownload() 실행
  @override
  void openDownload() {
    if (downloadStatus == DownloadStatus.downloaded) {
      _onOpenDownload();
    }
  }

  // 중요!! futer type _doSimulatedDownload 함수
  Future<void> _doSimulatedDownload() async {
    // _isDownloading 을 true ,  _downloadStatus 을 fetch 상태로 놓고 상태가 변경되었기 때문에 notifyListener 를 호출해 Provider 에서 알린다
    _isDownloading = true;
    _downloadStatus = DownloadStatus.fetchingDownload;
    notifyListeners();

    // fetch time 동안 1초 씩 progress delayed 시키기
    await Future<void>.delayed(const Duration(seconds: 1));

    // 만약 유저가 cancel button 누르면 download 중단 하고 return 시킴 (종료)
    if (!_isDownloading) {
      return;
    }

    // 다운로드 단계로 들어가는것으로 _downloadStatus 설정 후 notifyListener 를 호출해 Provider 에서 알린다
    _downloadStatus = DownloadStatus.downloading;
    notifyListeners();

    // 다운로드 progeress 단계 임의로 설정
    const downloadProgressStops = [0.0, 0.15, 0.45, 0.80, 1.0];
    // 반복문 시작 하나씩 돌면서 1초씩 delayed 설정
    for (final stop in downloadProgressStops) {
      // 1초마다 downloadProgressStops 설정
      await Future<void>.delayed(const Duration(seconds: 1));

      // 만약 유저가 cancel button 누르면 반복문 종료 시킴
      if (!_isDownloading) {
        return;
      }

      // for loop 돌때마다 _progress 를 list 의 items 로 설정 즉, 완료 되면 _progress = 1.0 됨
      _progress = stop;
      // for loop 돌때마다 state 변경되기때문에 notifyListeners() 호출
      notifyListeners();
    }

    // await Future<void>.delayed(const Duration(seconds: 1));

    // If the user chose to cancel the download, stop the simulation.
    // if (!_isDownloading) {
    //   return;
    // }

    // 다운로드 완료 되었기 때문에 DownloadStatus downloaded 로 설정하고 _isDownloading = false 하고 notifyListeners() 호출
    _downloadStatus = DownloadStatus.downloaded;
    _isDownloading = false;
    notifyListeners();
  }
}

// in download_button.dart

import 'package:cook_book_practice/download_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

class DownloadButton extends StatelessWidget {
  // download 버튼 constructor
  const DownloadButton({
    Key? key,
    required this.status,
    this.downloadProgress = 0.0,
    required this.onDownload,
    required this.onCancel,
    required this.onOpen,
    this.transitionDuration = const Duration(milliseconds: 500),
  }) : super(key: key);

  // download 버튼 값 초기화
  final DownloadStatus status;
  final double downloadProgress;
  final VoidCallback onDownload;
  final VoidCallback onCancel;
  final VoidCallback onOpen;
  final Duration transitionDuration;

  //  dowinloading, fetching, downloaded getter
  bool get _isDownloading => status == DownloadStatus.downloading;

  bool get _isFetching => status == DownloadStatus.fetchingDownload;

  bool get _isDownloaded => status == DownloadStatus.downloaded;

  // _onPressed 함수 생성
  void _onPressed() {
    // 각 상태 일때
    switch (status) {
      case DownloadStatus.notDownloaded:
        onDownload();
        break;
      case DownloadStatus.fetchingDownload:
        // fetchingDownload 일때는 아무것도 하지 않기.
        break;
      case DownloadStatus.downloading:
        onCancel();
        break;
      case DownloadStatus.downloaded:
        onOpen();
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // 버튼 클릭 시 _onPressed() 실행
      onTap: _onPressed,
      // stack 으로 버튼이 겹치게 설정
      child: Stack(
        children: [
          // _buildButtonShape 실행
          _buildButtonShape(
            child: _buildText(context),
          ),
          // _buildDownloadingProgress 실행
          _buildDownloadingProgress(),
        ],
      ),
    );
  }

  Widget _buildButtonShape({
    required Widget child,
  }) {
    return AnimatedContainer(
      duration: transitionDuration,
      curve: Curves.ease,
      width: double.infinity,
      decoration: _isDownloading || _isFetching
          ? ShapeDecoration(
              shape: const CircleBorder(),
              color: Colors.white.withOpacity(0.0),
            )
          : const ShapeDecoration(
              shape: StadiumBorder(),
              color: CupertinoColors.lightBackgroundGray,
            ),
      child: child,
    );
  }

  Widget _buildText(BuildContext context) {
    final text = _isDownloaded ? 'OPEN' : 'GET';
    final opacity = _isDownloading || _isFetching ? 0.0 : 1.0;

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: AnimatedOpacity(
        duration: transitionDuration,
        opacity: opacity,
        curve: Curves.ease,
        child: Text(
          text,
          textAlign: TextAlign.center,
          style: Theme.of(context).textTheme.button?.copyWith(
                fontWeight: FontWeight.bold,
                color: CupertinoColors.activeBlue,
              ),
        ),
      ),
    );
  }

  Widget _buildDownloadingProgress() {
    return Positioned.fill(
      child: AnimatedOpacity(
        duration: transitionDuration,
        opacity: _isDownloading || _isFetching ? 1.0 : 0.0,
        curve: Curves.ease,
        child: Stack(
          alignment: Alignment.center,
          children: [
            _buildProgressIndicator(),
            if (_isDownloading)
              const Icon(
                Icons.stop,
                size: 14.0,
                color: CupertinoColors.activeBlue,
              ),
          ],
        ),
      ),
    );
  }

  Widget _buildProgressIndicator() {
    return AspectRatio(
      aspectRatio: 1.0,
      child: TweenAnimationBuilder<double>(
        tween: Tween(begin: 0.0, end: downloadProgress),
        duration: const Duration(milliseconds: 200),
        builder: (context, progress, child) {
          return CircularProgressIndicator(
            backgroundColor: _isDownloading
                ? CupertinoColors.lightBackgroundGray
                : Colors.white.withOpacity(0.0),
            valueColor: AlwaysStoppedAnimation(_isFetching
                ? CupertinoColors.lightBackgroundGray
                : CupertinoColors.activeBlue),
            strokeWidth: 2.0,
            value: _isFetching ? null : progress,
          );
        },
      ),
    );
  }
}

// in demo_app_icon.dart

import 'package:flutter/material.dart';

class DemoAppIcon extends StatelessWidget {
  const DemoAppIcon({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const AspectRatio(
      aspectRatio: 1.0,
      child: FittedBox(
        child: SizedBox(
          width: 80.0,
          height: 80.0,
          child: DecoratedBox(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                colors: [Colors.red, Colors.blue],
              ),
              borderRadius: BorderRadius.all(Radius.circular(20.0)),
            ),
            child: Center(
              child: Icon(
                Icons.ac_unit,
                color: Colors.white,
                size: 40.0,
              ),
            ),
          ),
        ),
      ),
    );
  }
}


🔶 🔷 📌 🔑

Reference

Flutter cookbook - https://flutter.dev/docs/cookbook

Categories:

Updated:

Leave a comment