【Flutter】デジタル&アナログ時計を作ってみる - TimerとTickerの比較 ⏰

2021/08/30に公開
1

はじめに

Flutterで時計を作る場合、大きく分けて Timer.periodic を使う方法と Ticker を使う方法があるかと思います。今回は両アプローチを比較検討する時計を作ってみました。

デジタル&アナログ時計
デジタル&アナログ時計

👇 完成コード(実行可)
https://dartpad.dev/?null_safety=true&id=df6a4710cec41947be216688e0458cfa

Timerとは

コールバックの実行にタイマーをかけられるクラスです。一度きりの実行でも、繰り返し実行するものでも可能。繰り返し実行するものはTimer.periodicのコンストラクタを使用します。

Tickerとは

Flutterの画面更新のタイミングに合わせて(1000ms/60フレーム ≒ 16.7ms)、指定したコールバックを実行するクラスです。Timerと異なり、インスタンス作成後すぐ実行されず、明示的にstart()する必要があります。

TickerのTickはチクタク(tick-tack)と時計が時を刻むイメージですね。Tickerはアニメーションを使うときに使用する(Single)TickerProviderMixinでも使われています。

全体の構成

アプリ全体を図解するとこのような感じです。

全体の図解
全体の図解

Builderを作る

まずは一定の頻度でWidgetを再描画(buildメソッドを実行)してくれる、Builder Widgetを作ってみます。

Timer Builderを作る

class TimerBuilder extends StatefulWidget {
  
  _TimerBuilderState createState() => _TimerBuilderState();
}

class _TimerBuilderState extends State<TimerBuilder> {
  late DateTime _time;
  late Timer _timer;

  
  void initState() {
    super.initState();
    _time = DateTime.now();
    _timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
      setState(() {
        _time = DateTime.now();
      });
    });
  }

  
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return ConsolidatedClock(time: _time);
  }
}

初回build時とその後の100msごとに現在日時を取得して、setState()することで画面に最新の状態を反映します。100msと設定したのは適当なので、好みの滑らかさに応じて適当に数値を変更してください。

ConsolidatedClockはデジタルとアナログを合体した時計Widgetです。このWidgetにTimerのコールバックで取得した現在日時を渡しています。

Ticker Builderを作る

class TickerBuilder extends StatefulWidget {
  
  _TickerBuilderState createState() => _TickerBuilderState();
}

class _TickerBuilderState extends State<TickerBuilder>
    with SingleTickerProviderStateMixin {
  late final Ticker _ticker;
  late DateTime _time;

  
  void initState() {
    super.initState();
    _time = DateTime.now();
    _ticker = createTicker((elapsed) {
      setState(() {
        _time = DateTime.now();
      });
    });
    _ticker.start();
  }

  
  void dispose() {
    _ticker.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return ConsolidatedClock(time: _time);
  }
}

Timerの場合とほとんど同じですね。

createTicker()はSingleTickerProviderStateMixinのメソッドです。

実は SingleTickerProviderStateMixinを実装せずに、createTickerの部分をそのままTickerとしても時計は動きます。(createTickerは基本Tickerインスタンスを返すだけ)

しかし、createTicker()を使うことで後述するメリットが得られるのでここは素直にcreateTicker()を使います。(Tickerを直で使うことはあまりない?のかと思います)

これでTimerとTickerの両アプローチで、一定間隔でWidgetを再構築してくれるBuilderができました。あとはUIの部分を作るだけです。

デジタル時計を作る

クラスを2つに分けます。

  1. デジタル時計全体(時分数秒ms)を描画するクラス
  2. 時分秒msごとに数字を描画するクラス(数字一桁単位で横幅を固定)

2.で数字一桁単位で横幅の固定が必要なのは、そうしないと時分秒msを含めた全体の横幅が時間ごとに変わってしまうからです(等幅フォントでない限り、数字ごとに横幅が異なる)。

横幅固定前
❌ なんか揺れてる 😭

横幅固定後
🙆‍ スッキリ 😄

デジタル時計全体を描画するクラス

class DigitalClockRenderer extends StatelessWidget {
  final DateTime time;
  final double digitWidth;
  final TextStyle? style;

  const DigitalClockRenderer({
    Key? key,
    required this.time,
    this.digitWidth = 12.0,
    this.style,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TimeContainer(
              time: time.hour,
              digits: 2,
              digitWidth: digitWidth,
              suffix: ':',
              style: style,
            ),
            TimeContainer(
              time: time.minute,
              digits: 2,
              digitWidth: digitWidth,
              suffix: ':',
              style: style,
            ),
            TimeContainer(
              time: time.second,
              digits: 2,
              digitWidth: digitWidth,
              style: style,
            ),
          ],
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TimeContainer(
              time: time.millisecond,
              digits: 3,
              digitWidth: digitWidth,
              style: style,
            ),
          ],
        ),
      ],
    );
  }
}

スペースの関係上、ミリ秒の部分だけ別行としています。

時分秒ms単位で数字を描画するクラス

class TimeContainer extends StatelessWidget {
  final int time;
  final int digits;
  final double digitWidth;
  final String? suffix;
  final TextStyle? style;

  const TimeContainer({
    Key? key,
    required this.time,
    required this.digits,
    this.digitWidth = 12.0,
    this.suffix,
    this.style,
  }) : super(key: key);

  String get timeText => time.toString().padLeft(digits, '0');

  
  Widget build(BuildContext context) {
    return Row(
      children: [
        for (var i = 0; i < digits; i++) ...[
          Container(
            alignment: Alignment.center,
            width: digitWidth,
            child: Text(
              timeText[i],
              style: style,
            ),
          ),
          if (i == digits - 1)
            Container(
              alignment: Alignment.center,
              child: Text(
                suffix ?? '',
                style: style,
              ),
            ),
        ],
      ],
    );
  }
}

各メンバーの説明は以下の通りです。

(変数)

  • time -> 時間単位の数字。
  • digits -> 最大桁数。
  • digitWidth -> 桁ごとの横幅。
  • suffix -> 数字の後に表示する文字(コロンなど。一桁目の直後のみ表示)

(メソッド)

  • timeText -> 時間の数字を文字列に変換(桁数に満たない部分は'0'で埋める)

アナログ時計を作る

クラスを大体3つに分けます。

  • アナログ時計全体を描画するクラス
  • 時間の進み具合を針で表すクラス
  • 時計の周囲の文字盤を描画するクラス

アナログ時計全体を描画するクラス

class AnalogClockRenderer extends StatelessWidget {
  final DateTime time;
  final double radius;
  final Color plateColor;
  final Color dialColor;
  final Color secondColor;
  final Color minuteColor;
  final Color hourColor;

  const AnalogClockRenderer({
    Key? key,
    required this.time,
    this.radius = 120.0,
    this.plateColor = Colors.black,
    this.dialColor = Colors.white,
    this.secondColor = Colors.red,
    this.minuteColor = Colors.grey,
    this.hourColor = Colors.grey,
  }) : super(key: key);

  int get secondsInMillisecond => time.second * 1000 + time.millisecond;
  int get minutesInSecond => time.minute * 60 + time.second;
  int get hoursInMinute => (time.hour % 12) * 60 + time.minute;

  
  Widget build(BuildContext context) {
    return SizedBox(
      width: double.infinity,
      height: double.infinity,
      child: Stack(
        alignment: Alignment.center,
        children: [
          for (var i = 0; i < 60; i++)
            ClockMarker(
              index: i,
              radius: radius,
              markerWidth: 1,
              markerHeight: 12,
              fontSize: 18,
            ),
          // 秒針
          ClockHand(
            angle: (2 * pi) * (secondsInMillisecond / (60 * 1000)),
            thickness: 1,
            length: 140,
            color: Colors.pink,
          ),
          // 分針
          ClockHand(
            angle: (2 * pi) * (minutesInSecond / (60 * 60)),
            thickness: 6,
            length: 120,
            color: Colors.white70,
            shadowColor: Colors.amber,
          ),
          // 時針
          ClockHand(
            angle: (2 * pi) * (hoursInMinute / (60 * 12)),
            thickness: 8,
            length: 82,
            color: Colors.white70,
            shadowColor: Colors.amber,
          ),
          const ClockCenterCircle(
            size: 27,
            color: Colors.amber,
          ),
        ],
      ),
    );
  }
}

ゲッターのsecondsInMillisecondは、現在時刻の秒数(上位単位も含めた秒数ではなく、時計に表示されるままの秒数)をミリ秒数も含めて、ミリ秒で取得しています。現在時刻が1分30秒100ミリ秒なら、90,100msではなく、分を除いた30,100ms。秒針を動かすのに使う指標です。

ミリ秒も含める理由は、秒針を1秒より小さい単位(60fpsの1フレームなど)で動かすためです。以降のminutesInSecond、hoursInMinuteについても同様です。

Stackのalignmentは部品をいったんWidgetのど真ん中に集めるためAlignment.centerとします。これで部品の位置をずらしたり、回転するのがやりやすくなります。

時間の進み具合を針で表すクラス

class ClockHand extends StatelessWidget {
  final double angle;
  final double thickness;
  final double length;
  final Color color;
  final Color? shadowColor;

  const ClockHand({
    Key? key,
    required this.angle,
    required this.thickness,
    required this.length,
    required this.color,
    this.shadowColor,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    return Transform(
      alignment: Alignment.bottomCenter,
      transform: Matrix4.identity()
        ..translate(0.0, -length / 2)
        ..rotateZ(angle),
      child: Container(
        width: thickness,
        height: length,
        decoration: BoxDecoration(
          color: color,
          boxShadow: [
            if (shadowColor != null)
              BoxShadow(
                color: shadowColor!,
                blurRadius: 12,
                spreadRadius: 1,
              )
          ],
          borderRadius: BorderRadius.circular(thickness),
        ),
      ),
    );
  }
}

CustomPainter(+三角関数)を使わずにTransformで針の角度を調節します。

一つ前のWidgetの中央点とこのWidgetの中央点が重なっている状態(のはず、、)なので、あとは縦幅の半分の値だけY方向にずらして針を突き出し、Z方向(画面に対して直角の方向)を軸に時間に応じた回転を加えるだけです。

    return Transform(
      alignment: Alignment.bottomCenter,
      transform: Matrix4.identity()
        ..translate(0.0, -length / 2)
        ..rotateZ(angle),

変形の基準点はAlignment.bottomCenter(針の下中央)となる点、ご注意ください。

angleは秒針の場合、

AnalogClockRendererより
angle: (2 * pi) * (secondsInMillisecond / (60 * 1000))

2 * pi(半径1とした場合の円周)に秒の進み具合をかけた値になります。秒の進み具合は、分母が60秒(1分)×1000ms、分子が前述のsecondsInMillisecondで表せます。

時計の周囲の文字盤を描画するクラス

文字盤は円を特定の数で分割してマーカーを置き、そのうちのいくつかを数字で、残りを■で表します。それをできるだけ抽象的に表現して汎用性を高めます。

class ClockMarker extends StatelessWidget {
  final int index;
  final int stops;
  final double radius;
  final double markerWidth;
  final double markerHeight;
  final Color markerColor;
  final double fontSize;
  final Color fontColor;
  final int representativeSplit;
  final int maxRepresentative;

  const ClockMarker({
    Key? key,
    required this.index,
    this.stops = 60,
    required this.radius,
    required this.markerWidth,
    required this.markerHeight,
    this.markerColor = Colors.white54,
    required this.fontSize,
    this.fontColor = Colors.white,
    this.representativeSplit = 12,
    this.maxRepresentative = 12,
  })  : super(key: key);

  bool get isRepresentative => index % (stops / representativeSplit) == 0;

  String get representativeText {
    final index = this.index == 0 ? stops : this.index;
    return (maxRepresentative * (index / stops)).toStringAsFixed(0);
  }

  
  Widget build(BuildContext context) {
    return Transform(
      alignment: Alignment.center,
      transform: Matrix4.identity()
        // 回転軸はContainerの中央にあるので、必ず回転してから移動
        ..rotateZ(2.0 * pi * (index / stops))
        // 0などintで渡してもdoubleに変換されずエラーが出るので注意
        ..translate(0.0, -radius),
      child: !isRepresentative
          ? Container(
              width: markerWidth,
              height: markerHeight,
              color: markerColor,
            )
          : Container(
              width: fontSize * 2,
              alignment: Alignment.center,
              // 数字が読めるように上記Transformの回転を相殺する
              transform: Matrix4.identity()
                ..rotateZ(-2.0 * pi * (index / stops)),
              transformAlignment: Alignment.center,
              child: Text(
                representativeText,
                style: TextStyle(
                  color: fontColor,
                  fontSize: fontSize,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
    );
  }
}

まずはメンバーの説明から。

(変数)

  • index -> てっぺんから時計回りで何番目のマーカーか。てっぺんは常に0。
  • stops -> マーカーの総数。円を何分割するか。
  • radius -> 時計の半径。
  • markerWidth -> マーカーの横幅。
  • markerHeight -> マーカーの高さ。
  • markerColor -> マーカーの色。
  • fontSize -> マーカーが数字の場合の大きさ。
  • fontColor -> マーカーが数字の場合の色。
  • representativeSplit -> 数字マーカーの総アイテム数。円を数字マーカーで何分割するか。
  • maxRepresentative -> 数字マーカーの最大値。

(メソッド)

  • isRepresentative -> 数字で表すべきマーカーなのかどうかを判別。例えば、円が60分割されているなら、indexが「60/数字マーカーの総数」で割り切れればtrueになる。
  • representativeText -> isRepresentativeなマーカーの数字テキストを取得。0(てっぺん)の場合は数字マーカーの最大値に差し替える。

ここでもTransformを活用してマーカーの位置や角度を時間に応じて変化させます。

    return Transform(
      alignment: Alignment.center,
      transform: Matrix4.identity()
        ..rotateZ(2.0 * pi * (index / stops))
        ..translate(0.0, -radius),

注意点として、この場合は回転の基準点がマーカーの中央にあるため、秒針のときとは異なり回転を済ませてから位置をずらす必要があります。順序が逆だとこのようになってしまいます。

translateからのrotateZ
❌ translateからのrotateZ 😭

rotateZからのtranslate
🙆‍♂️ rotateZからのtranslate 😄

また変形の基準点も、秒針のときはAlignment.bottomCenterでしたが、ここではAlignment.centerとなります。

translateからのrotateZ & bottomCenter
❌ bottomCenter & translateからのrotateZ 😭

rotateZからのtranslate & bottomCenter
❌ bottomCenter & rotateZからのtranslate 😭


そして数字マーカーの場合は回転したままだと数字が読めないため、さらに変形して回転を元に戻す(相殺する)必要があります。ここではContainerのtransformプロパティを利用します。

          : Container(
              width: fontSize * 2,
              alignment: Alignment.center,
              transform: Matrix4.identity()
                ..rotateZ(-2.0 * pi * (index / stops)),
              transformAlignment: Alignment.center,
              child: Text( // 省略

回転を相殺しない場合
❌ 回転を相殺しない場合 😭

またtransformAlignmentとともにalignmentもAlignment.centerにします。数字や桁数によって横幅が異なるため、Containerの中で中央に寄せないと円がずれてしまいます。

alignmentを指定しない場合
❌ alignmentを指定しない場合、微妙に左にズレる 😭

デジタルとアナログの融合

最後にデジタル時計とアナログ時計を結合したクラスを作成し、冒頭で作成した2つのBuilderクラスそれぞれでインスタンス化します。そしてそれらを縦に並べます。

全体の図解を再掲します。

全体の図解
全体の図解

比較してみての結論

当然ですが、見た目は変わりません。。笑

(Timerの更新頻度をあえて100msにしているので、その分滑らかさに違いは出ますが50msくらい以下にしてみたらTickerとの違いは私の目では分からなかったです)

ただ、TickerはTimerと異なりWidgetが見えないところではコールバックが発動せずミュート状態になるようなので(ただしSingle/TickerProvoiderStateMixinを実装してcreateTicker()からTickerインスタンスを作成した場合)、その分メリットがある気がします。

https://dartpad.dev/?null_safety=true&id=df6a4710cec41947be216688e0458cfa

👆ではFloatingActionButtonで別ページへのRouteを作成していますが、確かに別ページで時計が隠れている間、Timerが発動し続けているのに対してTickerは止まっていました。(元の時計ページに戻ったら問題なくTicker再開)

ということで、一定の間隔でコールバックを実行するようなアプリで、1秒より小さい単位で画面更新する必要がある場合やFlutterのフレームレートに合わせて正確な表示をしたい場合はTickerを使用するのがベターかと思います。

最後に

この記事はこちらを参考にさせていただきました。ありがとうございました。
https://codewithandrea.com/articles/flutter-timer-vs-ticker/

過去記事。
https://zenn.dev/inari_sushio/articles/620b436122cd03
https://zenn.dev/inari_sushio/articles/9643f20ebff29d
https://zenn.dev/inari_sushio/articles/9874164f04f89b
https://zenn.dev/inari_sushio/articles/4e228c29d792ab

Discussion

Flutter_坂上忍Flutter_坂上忍

ConsolidatedClockはclassを作らないといけない感じですか?
classを作れとエラーで言われたので作りましたら次にConsolidatedClockの横のtimeを定義しろと忠告されました。お手隙で構いませんので返答いただければありがたいです。