Flutterで一定時間後にアニメーションを開始する

# 作ったアニメーション

Flutter Webで[スプラトゥーン2プレイヤー向けの動画プレイヤーアプリ](https://ikut.app/)を作成しました。このアプリはカーソルキーの左右や画像認識で抽出されたシーンの時刻クリックで再生箇所をジャンプすることができます。そのとき、動画の上にジャンプ先時刻をカットイン表示した後、0.7秒後に0.3秒かけてフェードアウトします。

![ezgif-3-c1835ec66fbb.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/dae970cb-d5ca-af81-b63b-f75accd8265a.gif)

※ 動画プレイヤー内に表示されている動画は任天堂株式会社のスプラトゥーン2からの引用です。


# 作り方

Flutterでフェードアウトなどのアニメーションを行う方法に[Animetedと付いているWidgetを使う方法](https://flutter.dev/docs/development/ui/animations/implicit-animations)があります。フェードアウトの場合は[AnimatedOpacity](https://api.flutter.dev/flutter/widgets/AnimatedOpacity-class.html)クラスを使います。しかし、それだけでは「0.7秒後にアニメーションを開始する」という設定ができず、[Timer](https://api.flutter.dev/flutter/dart-async/Timer-class.html)クラスと組み合わせて使いました。手順を１つずつ解説します。

## 前提

アプリは以下のパッケージを使い構築しています。

- [hooks_riverpod](https://pub.dev/packages/hooks_riverpod)
- [freezed](https://pub.dev/packages/freezed)

## 時刻表示の状態を表すクラスを作成する

```video_time.dart
@freezed
abstract class VideoTime with _$VideoTime {
  /// [currentTime] ジャンプ先時刻
  /// [visible] 表示フラグ
  /// [fadeOut] フェードアウトフラグ
  factory VideoTime(double currentTime, bool visible,  bool fadeOut) = _VideoTime;
}
```

## 時刻表示の状態を変更するStateNotifierとそのProviderを作成する

```video_time_state_notifier_provider.dart
class VideoTimeStateNotifier extends StateNotifier<VideoTime> {
  VideoTimeStateNotifier() : super(VideoTime(0.0, false, false));

  /// 表示時刻を更新する
  /// [currentTime] 表示時刻
  void setTime(double currentTime) {
    state =
        state.copyWith(currentTime: currentTime, visible: true, fadeOut: false);
  }

  /// フェードアウトをリクエストする
  void requestFadeOut() {
    state = state.copyWith(fadeOut: true);
  }
}

final videoTimeStateNotifierProvider =
    StateNotifierProvider((_) => VideoTimeStateNotifier());

```

再生時刻の変更が発生したときは、VideoTimeStateNotifierクラスのsetTimeメソッドが呼ばれます。

## 時刻表示のWidgetを構築する

ここで重要なのは[useEffect](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html)を使うことです。その理由は次の節で説明します。

```video_time_builder.dart
HookBuilder makeVideoTimeBuilder() {
  return HookBuilder(builder: (context) {
    // 状態を取得
    final videoTime = useProvider(videoTimeStateNotifierProvider.state);
    // 不透明度
    double opacity = 1.0;
    // アニメーションのミリ秒
    int ms = 0;
    // フェードアウトするときは、0.3秒かけて不透明度0に向かう。
    if (videoTime.fadeOut) {
      opacity = 0.0;
      ms = 300;
    }
    // 0.7後にフェードアウトをリクエスト
    if (videoTime.visible && !videoTime.fadeOut) {
      // 重要
      useEffect(() {
        Timer timer = Timer(Duration(milliseconds: 700), () {
          // 0.7秒後に実行されるブロック
          // フェードアウトをリクエスト
          final videoTimeStateNotifier =
          context.read(videoTimeStateNotifierProvider);
          videoTimeStateNotifier.requestFadeOut();
        });
        return () {
          // このウィジットが破棄されたときに呼ばれる
          timer.cancel();
        };
      });
    }
    final textStyle = TextStyle(fontSize: 34, color: Colors.white);
    return Visibility(
      visible: videoTime.visible,
      child: Center(
          child: AnimatedOpacity(
        opacity: opacity,
        duration: Duration(milliseconds: ms),
        child: Container(
          decoration: ShapeDecoration(
              color: Color.fromARGB(0xbb, 0, 0, 0), shape: StadiumBorder()),
          padding: EdgeInsets.symmetric(vertical: 16, horizontal: 32),
          child: Text(toTimeString(videoTime.currentTime), style: textStyle),
        ),
      )),
    );
  });
}
```

## useEffectを使う理由

実はこのアプリ、ブラウザ横幅によってレイアウトが違います。動画プレイヤーとは別に画像認識で抽出されたシーン一覧があり、横幅が狭いときは下に、広いときは右に配置されます。

![ezgif-3-589367bc2669.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/354457e9-8a84-2e93-8604-ee00590e9991.gif)

そして、そのウィジット配置の変更が行われる時に、ウィジットが破棄されるのですが、破棄された後で0.7秒後のコールバックが実行されると、以下のようなエラーが出ます。

```
Error: Looking up a deactivated widget's ancestor is unsafe.
At this point the state of the widget's element tree is no longer stable.
To safely refer to a widget's ancestor in its dispose() method, save a reference to the ancestor by calling dependOnInheritedWidgetOfExactType() in the widget's didChangeDependencies() method.
```

よって、ウィジットが破棄されたらタイマーをキャンセルして0.7秒後のコールバックが呼ばれないようにしています。useEffectに渡すラムダの戻り値は破棄されたタイミングで呼ばれるラムダになります。

# 感想

私は普段Androidネイティブでアプリを開発していて、このようなアニメーションは[ObjectAnimator](https://developer.android.com/reference/android/animation/ObjectAnimator)クラスの[setStartDelay](https://developer.android.com/reference/android/animation/Animator#setStartDelay(long))メソッドで行うのですが、Flutterはやり方が違うと思いました。