flutter系列之:做一個下載按鈕的動畫

2023-05-24 18:00:40

簡介

我們在app的開發過程中經常會用到一些表示進度類的動畫效果,比如一個下載按鈕,我們希望按鈕能夠動態顯示下載的進度,這樣可以給使用者一些直觀的印象,那麼在flutter中一個下載按鈕的動畫應該如何製作呢?

一起來看看吧。

定義下載的狀態

我們在真正開發下載按鈕之前,首先定義幾個下載的狀態,因為不同的下載狀態導致的按鈕展示樣子也是不一樣的,我們用下面的一個列舉類來設定按鈕的下載狀態:

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

基本上有4個狀態,分別是沒有下載,準備下載但是還沒有獲取到下載的資源連結,獲取到下載資源正在下載中,最後是下載完畢。

定義DownloadButton的屬性

這裡我們需要自定義一個DownloadButton元件,這個元件肯定是一個StatelessWidget,所有的狀態資訊都是由外部傳入的。

我們需要根據下載狀態來指定DownloadButton的樣式,所以需要一個status屬性。下載過程中還有一個下載的進度條,所以我們需要一個downloadProgress屬性。

另外在點選下載按鈕的時候會觸發onDownload事件,下載過程中可以觸發onCancel事件,下載完畢之後可以出發onOpen事件。

最後因為是一個動畫元件,所以還需要一個動畫的持續時間屬性transitionDuration。

所以我們的DownloadButton需要下面一些屬性:

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),
  });

讓DownloadButton的屬性可以動態變化

上面提到了DownloadButton是一個StatelessWidget,所有的屬性都是由外部傳入的,但是對於一個動畫的DownloadButton來說,status,downloadProgress這些資訊都是會動態變化的,那麼怎麼才能讓變化的屬性傳到DownloadButton中進行元件的重繪呢?

因為涉及到複雜的狀態變化,所以簡單的AnimatedWidget已經滿足不了我們的需求了,這裡就需要用到flutter中的AnimatedBuilder元件了。

AnimatedBuilder是AnimatedWidget的子類,它有兩個必須的引數,分別是animation和builder。

其中animation是一個Listenable物件,它可以是Animation,ChangeNotifier或者等。

AnimatedBuilder會通過監聽animation的變動情況,來重新構建builder中的元件。buidler方法可以從animation中獲取對應的變動屬性。

這樣我們建立一個Listenable的DownloadController物件,然後把DownloadButton用AnimatedBuilder封裝起來,就可以實時監測到downloadStatus和downloadProgress的變化了。

如下所示:

Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('下載按鈕')),
      body: Center(
        child: 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,
              );
            },
          ),
        ),
      ),
    );
  }

定義downloadController

downloadController是一個Listenable物件,這裡我們讓他實現ChangeNotifier介面,並且定義了兩個獲取下載狀態和下載進度的方法,同時也定義了三個點選觸發事件:

abstract class DownloadController implements ChangeNotifier  {
  DownloadStatus get downloadStatus;
  double get progress;

  void startDownload();
  void stopDownload();
  void openDownload();
}

接下來我們來實現這個抽象方法:

class MyDownloadController extends DownloadController
    with ChangeNotifier {
  MyDownloadController({
    DownloadStatus downloadStatus = DownloadStatus.notDownloaded,
    double progress = 0.0,
    required VoidCallback onOpenDownload,
  })  : _downloadStatus = downloadStatus,
        _progress = progress,
        _onOpenDownload = onOpenDownload;

startDownload,stopDownload這兩個方法是跟下載狀態和下載進度相關的,先看下stopDownload:

  void stopDownload() {
    if (_isDownloading) {
      _isDownloading = false;
      _downloadStatus = DownloadStatus.notDownloaded;
      _progress = 0.0;
      notifyListeners();
    }
  }

可以看到這個方法最後需要呼叫notifyListeners來通知AnimatedBuilder來進行元件的重繪。

startDownload方法會複雜一點,我們需要模擬下載狀態的變化和進度的變化,如下所示:

  Future<void> _doDownload() async {
    _isDownloading = true;
    _downloadStatus = DownloadStatus.fetchingDownload;
    notifyListeners();

    // fetch耗時1秒鐘
    await Future<void>.delayed(const Duration(seconds: 1));

    if (!_isDownloading) {
      return;
    }

    // 轉換到下載的狀態
    _downloadStatus = DownloadStatus.downloading;
    notifyListeners();

    const downloadProgressStops = [0.0, 0.15, 0.45, 0.8, 1.0];
    for (final progress in downloadProgressStops) {
      await Future<void>.delayed(const Duration(seconds: 1));
      if (!_isDownloading) {
        return;
      }
      //更新progress
      _progress = progress;
      notifyListeners();
    }

    await Future<void>.delayed(const Duration(seconds: 1));
    if (!_isDownloading) {
      return;
    }
    //切換到下載完畢狀態
    _downloadStatus = DownloadStatus.downloaded;
    _isDownloading = false;
    notifyListeners();
  }
}

因為下載是一個比較長的過程,所以這裡用的是非同步方法,在非同步方法中進行通知。

定義DownloadButton的細節

有了可以動態變化的狀態和進度之後,我們就可以在DownloadButton中構建具體的頁面展示了。

在未開始下載之前,我們希望downloadButton是一個長條形的按鈕,按鈕上的文字顯示GET,下載過程中希望是一個類似CircularProgressIndicator的動畫,可以根據下載進度來動態變化。

同時,在下載過程中,我們希望能夠隱藏之前的長條形按鈕。 下載完畢之後,再次展示長條形按鈕,這時候按鈕上的文字顯示為OPEN。

因為動畫比較複雜,所以我們將動畫元件分成兩部分,第一部分就是展示和隱藏長條形的按鈕,這裡我們使用AnimatedOpacity來實現文字的淡入淡出的效果,並將AnimatedOpacity封裝在AnimatedContainer中,實現decoration的動畫效果:

  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.button?.copyWith(
              fontWeight: FontWeight.bold,
              color: CupertinoColors.activeBlue,
            ),
          ),
        ),
      ),
    );

實現效果如下所示:

接下來再處理CircularProgressIndicator的部分:

 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,
          );
        },
      ),
    );
  }

這裡使用的是TweenAnimationBuilder來實現CircularProgressIndicator根據不同progress的動畫效果。

因為在下載過程中,還有停止的功能,所以我們在CircularProgressIndicator上再放一個stop icon,最後將這個stack封裝在AnimatedOpacity中,實現整體的一個淡入淡出功能:

         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,
                    ),
                ],
              ),
            ),

總結

這樣,我們一個動畫的下載按鈕就製作完成了,效果如下:

本文的例子:https://github.com/ddean2009/learn-flutter.git