Flutter 中“倒數計時”的那些事兒

水月沐風發表於2019-12-02

好久不見了,文章有一段時間沒有更新了,最近一直在沉迷工作無法自撥?。上週,應公司號召以及上次Google大會中Flutter宣講的感染,計劃將公司新專案採用Flutter技術實現。大概花了幾天熟悉了一下Flutter基礎語法和結構組成,便著手開始專案的搭建和基礎模組功能開發,畢竟只有通過實戰才能加快新技術的熟悉和“消化”。

說到驗證碼功能,我們通常的做法可能是藉助於計時器來實現,抱著幾乎肯定的態度趕緊去查了一下 Flutter 官網有沒有相關的計時器元件。果不其然,官方的確為我們提供了一個 Timer 元件來實現倒數計時,我們來看看官方對於它的描述:

A count-down timer that can be configured to fire once or repeatedly.

它是一個支援一次或者多次週期性觸發的計時器。首先,讓我們來熟悉這兩種場景下的基本用法。

單次觸發

這種情況下的一般場景是作為延時器來使用,我們並不會接收到倒數計時的進度,只會在倒數計時結束後收到回撥提醒。例如,我們來看下 Flutter 官方提供的示例程式碼:

const timeout = const Duration(seconds: 3);
const ms = const Duration(milliseconds: 1);

startTimeout([int milliseconds]) {
  var duration = milliseconds == null ? timeout : ms * milliseconds;
  return new Timer(duration, handleTimeout);
}
...
void handleTimeout() {  // callback function
  ...
}
複製程式碼

從上面程式碼可以看到,通過 new Timer(Duration duration, void callback()) 方式建立的定時器會提供一個 callback 回撥方法來處理計時結束後的操作。這顯然不符合我們驗證碼功能中實時顯示進度的需求,讓我們來看看 Timer 如何重複性觸發回撥。

週期性觸發

週期性觸發計時回撥的場景就很普遍了,只要是涉及到定時相關的操作可能都離不開它:我們既需要被告知計時什麼時候結束,也需要實時地監測計時的進度。這正符合了我們最初想要的驗證碼功能這個需求。dart-async 包 同樣為我們提供了 Timer.periodic 構造方法來建立一個可以重複回撥的計時器:

Timer.periodic(
	Duration duration,
	void callback(
		Timer timer
	)
)
複製程式碼

此外,官方是這樣描述 callback 引數的:

The callback is invoked repeatedly with duration intervals until canceled with the cancel function.

大概意思是:callback 回撥方法會伴隨時間推移而被多次呼叫(呼叫週期為 duration),直到呼叫 Timer.cancel 方法。值得注意的是,週期性回撥的計時器並不會“結束計時”,或者說它並不會自動結束計時任務,所以我們需要手動去統計時間並及時取消計時任務。具體如何操作呢?說了這麼多,下面讓我們進入本文正題:驗證碼倒數計時實現吧?。其實,該功能的實現程式碼很簡單,這裡就直接貼程式碼了:

	Timer _countDownTimer;
  int _currentTime = 60;
  bool get _isTimeCountingDown => _currentTime != 60;

	void _startTimeCountDown() {
    if (_countDownTimer != null) {
      timer.cancel();
      timer = null;
    }
    _countDownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
      if (timer.tick == 60) {
        _currentTime = 60;
        _countDownTimer.cancel();
        _countDownTimer = null;
      } else {
        _currentTime--;
      }
      setState(() {
      });
    });
  }

	@override
  void dispose() {
    _countDownTimer?.cancel();
    _countDownTimer = null;
    super.dispose();
  }
複製程式碼

我們可以通過 Timer.tick 來獲取當前計時(遞增)的進度,同時藉助於 _currentTime 來標記計時的進度值,其他的邏輯程式碼應該就比較好理解了。

什麼?你以為到這裡就完了?哈哈,當然不會,假如倒數計時功能需要在我們專案裡有很多不同的使用情景,那麼就該對倒數計時這個功能進行封裝了,況且,通過 setState 方式來實時展示倒數計時進度而去重新整理整個檢視樹著實不太友好。

倒數計時封裝

目前,Flutter 狀態管理方案呈現“百家爭鳴”之態,個人還是比較喜歡 Provider 來管理狀態。下面就用 Provider 來將倒數計時功能封裝為一個元件:

import 'dart:async';
import 'package:flutter/material.dart';

/// 計時器元件
class CountDownTimeModel extends ChangeNotifier {
  final int timeMax;
  final int interval;
  int _time;
  Timer _timer;

  int get currentTime => _time;

  bool get isFinish => _time == timeMax;

  CountDownTimeModel(this.timeMax, this.interval) {
    _time = timeMax;
  }

  void startCountDown() {
    if (_timer != null) {
      _timer.cancel();
      _timer = null;
    }
    _timer = Timer.periodic(Duration(seconds: interval), (timer) {
      if (timer.tick == timeMax) {
        _time = timeMax;
        timer.cancel();
        timer = null;
      } else {
        _time--;
      }
      notifyListeners();
    });
  }
  
  void cancel() {
    if (_timer != null) {
      _timer.cancel();
      _timer = null;
    }
  }

  @override
  void dispose() {
    _timer.cancel();
    _timer = null;
    super.dispose();
  }
}
複製程式碼

具體如何使用呢?熟悉 Provider 用法的小夥伴應該就不用多說了,這邊為了演示方便,就以下面這個效果為例:

Flutter 中“倒數計時”的那些事兒

點選“獲取驗證碼”按鈕開始 60 秒倒數計時服務,結束倒數計時過程中按鈕需要處於不可點選狀態,倒數計時結束後恢復點選狀態。具體程式碼如下:

class _LoginPageState extends State<TestPage> {
  @override
  Widget build(BuildContext context) {
    final logo = Hero(
      tag: 'hero',
      child: CircleAvatar(
        backgroundColor: Colors.transparent,
        radius: 48.0,
        child: Image.asset(ImageAssets.holder_logo),
      ),
    );

    final email = TextFormField(
      keyboardType: TextInputType.emailAddress,
      autofocus: false,
      initialValue: 'alucard@gmail.com',
      decoration: InputDecoration(
        hintText: 'Email',
        contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0),
        border: OutlineInputBorder(borderRadius: BorderRadius.circular(32.0)),
      ),
    );

    final password = TextFormField(
      autofocus: false,
      initialValue: 'some password',
      obscureText: true,
      decoration: InputDecoration(
        hintText: 'Password',
        contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0),
        border: OutlineInputBorder(borderRadius: BorderRadius.circular(32.0)),
      ),
    );

    final loginButton = Padding(
      padding: EdgeInsets.symmetric(vertical: 16.0),
      child: RaisedButton(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(24),
        ),
        onPressed: () {
        },
        padding: EdgeInsets.all(12),
        color: Colors.lightBlueAccent,
        child: Text('Log In', style: TextStyle(color: Colors.white)),
      ),
    );

    final forgotLabel = FlatButton(
      child: Text(
        'Forgot password?',
        style: TextStyle(color: Colors.black54),
      ),
      onPressed: () {},
    );

    return Scaffold(
      backgroundColor: Colors.white,
      body: Center(
        child: ListView(
          shrinkWrap: true,
          padding: EdgeInsets.only(left: 24.0, right: 24.0),
          children: <Widget>[
            logo,
            SizedBox(height: 48.0),
            email,
            SizedBox(height: 8.0),
            ScreenUtils.verticalSpace(2),
            Stack(
              children: <Widget>[
                password,

                PartialConsumeComponent<CountDownTimeModel>(
                  model: CountDownTimeModel(60, 1),
                  builder: (context, model, _) => Positioned(
                    right: 10,
                    bottom: 1,
                    top: 1,
                    child: FlatButton(
                        disabledColor: Colors.grey.withOpacity(0.36),
                        color: Colors.white70.withOpacity(0.7),
                        onPressed: !model.isFinish ? null : () {
                          model.startCountDown();
                        },
                        child: Text(
                          model.isFinish ? '獲取驗證碼' : model.currentTime.toString()+'秒後重新獲取',
                          style: TextStyle(color: model.isFinish ? Colors.lightBlueAccent : Colors.white),
                        )
                    ),
                  ),
                ),
              ],
            ),
            SizedBox(height: 24.0),
            loginButton,
            forgotLabel
          ],
        ),
      ),
    );
  }


}
複製程式碼

這裡,我們通過在 CountDownTimeModel 中定義的 isFinish 欄位來判斷倒數計時是否正在進行,進而處理按鈕的各種狀態(如顏色、點選狀態、文字內容等)。此處為了方便對當前頁面狀態進行管理,我單獨封裝了一個公用的消費者元件:

class PartialConsumeComponent<T extends ChangeNotifier> extends StatefulWidget {

  final T model;
  final Widget child;
  final ValueWidgetBuilder<T> builder;

  PartialConsumeComponent({
    Key key,
    @required this.model,
    @required this.builder,
    this.child
  }) : super(key: key);

  @override
  _PartialConsumeComponentState<T> createState() => _PartialConsumeComponentState<T>();

}

class _PartialConsumeComponentState<T extends ChangeNotifier> extends State<PartialConsumeComponent<T>> {

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<T>.value(
      value: widget.model,
      child: Consumer<T>(
          builder: widget.builder,
          child: widget.child,
      ),
    );
  }
}
複製程式碼

最後

本人剛接觸 Flutter 兩週左右,有些地方可能會為了開發進度而忽略一些細節,如有不嚴謹或者疏漏之處歡迎指正。此外,後續我將為大家帶來更多 Android 和 Flutter 方面文章,請期待。

參考

相關文章