✏️

【Flutter】RiverPodを使ってフェードイン/フェードアウトの連続アニメーション実装してみた

2024/01/04に公開

はじめに

最近、Flutterをはじめたので、勉強がてら作ったアプリを記事にしてみました。
※ Flutterの前は、SwiftでiOSアプリ開発をやってました。

今回、状態管理にRiverpodを採用しております。

実装したもの

使用している主なライブラリ等

コード

一般的にModel層やData層と言われるロジック部分から書いていきます。

表示するコンテンツを管理するためのEnumを作成

まずは表示したいコンテンツ(Text)と紐づくEnumを定義しました。
Widgetに表示する内容と紐づけるために、Colorやテキストもここに定義してます。

language_type.dart
enum LanguageType {
  swift,
  flutter,
  kotlin,
}

extension LanguageTypeExtension on LanguageType {
  Color get color {
    switch (this) {
      case LanguageType.swift:
        return Colors.deepOrange;
      case LanguageType.flutter:
        return Colors.lightBlue;
      case LanguageType.kotlin:
        return Colors.purple;
    }
  }
  String get text {
    switch (this) {
      case LanguageType.swift:
        return 'Swift';
      case LanguageType.flutter:
        return 'Flutter';
      case LanguageType.kotlin:
        return 'Kotlin';
    }
  }
}

このLanguageTypeを配列に入れて、コンテンツリストを作成し、先頭のものが画面に表示されるようにしたいです。

StateNotifierProviderを実装

まずはproviderで管理するNotifierを作成します。
freezedを採用した理由としては、StateNotifierProviderの解説の中で、

// StateNotifier のステート(状態)はイミュータブル(不変)である必要があります。
// ここは Freezed のようなパッケージを利用してイミュータブルにしても OK です。

とあるので、こちらも勉強がてら使ってみました。

language_type_list_notifier.dart
final languageTypeListProvider =
    StateNotifierProvider.autoDispose<LanguageTypeListNotifier, LanguageTypeListState>((ref) {
  return LanguageTypeListNotifier();
});


abstract class LanguageTypeListState with _$LanguageTypeListState {
  const factory LanguageTypeListState({
    // ここでコンテンツの配列を保持する
    (LanguageType.values) List<LanguageType> languageTypeList,
  }) = _LanguageTypeListState;
}

class LanguageTypeListNotifier extends StateNotifier<LanguageTypeListState> {
  LanguageTypeListNotifier([super._state = const LanguageTypeListState()]);
  // フェードアウトしたタイミングで配列をローテションし、次に表示されるコンテンツを変更する
  void rotateLanguageTypeList() {
    var localLanguageTypeList = state.languageTypeList.toList();
    localLanguageTypeList.add(localLanguageTypeList.removeAt(0));
    state = state.copyWith(languageTypeList: localLanguageTypeList);
  }
}

freezedを使っているので、クラスを作成したら、
dart run build_runner build --delete-conflicting-outputs
を実行して自動生成ファイルを作る必要があります。

var localLanguageTypeList = state.languageTypeList.toList();
localLanguageTypeList.add(localLanguageTypeList.removeAt(0));
state = state.copyWith(languageTypeList: localLanguageTypeList);

Animationの実装

fade_in_out_animation_controller.dart
class FadeInOutAnimationController extends AnimationController {
  FadeInOutAnimationController({
    required super.vsync,
    required super.duration,
    required void Function() completion,
    required this.setState,
  }) {
    _animation.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        // フェードインが完了したとき、フェードアウトを開始する
        reverse();
      } else if (status == AnimationStatus.dismissed) {
        // フェードアウトが完了したとき、completionを実行し、フェードアウトを開始する
        completion();
        forward();
      }
    });
  }
  final void Function(void Function()) setState;
  late final Animation<double> _animation = CurvedAnimation(
    parent: this,
    curve: Curves.easeIn,
  );

  get animation => _animation;

  // 再生・停止ボタンが押された時にアニメーションを制御する
  void pauseOrRestartAnimation() {
    setState(() {
      if (isAnimating) {
        stop();
      } else {
        // 現在のアニメーションの状態によって再生時の挙動を変える
        if (status == AnimationStatus.reverse) {
          reverse();
        } else {
          forward();
        }
      }
    });
  }
}

Widget側はsetStateするかどうかを意識せずに使いたいので、引数でsetStateを受け取ってAnimation自身で実行するようにしました。

final void Function(void Function()) setState;

Viewの実装

fade_in_out_page.dart
class FadeInOutPage extends ConsumerStatefulWidget {
  const FadeInOutPage({super.key, required this.title});

  final String title;

  
  ConsumerState<ConsumerStatefulWidget> createState() => _FadeInOutPageState();
}

class _FadeInOutPageState extends ConsumerState<FadeInOutPage> with SingleTickerProviderStateMixin {
  // アニメーションが終わった時に、StateNotifierProviderを通してコンテンツの入れ替えを行う
  late final _fadeAnimationController = FadeInOutAnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 900),
    completion: ref.read(languageTypeListProvider.notifier).rotateLanguageTypeList,
    setState: setState,
  );

  
  void initState() {
    super.initState();
    // 最初はコンテンツが表示されて欲しいので1.0にして停止する
    _fadeAnimationController.value = 1.0;
    _fadeAnimationController.stop();
  }

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: FadeTransition(
          opacity: _fadeAnimationController.animation,
          child: languageText(ref.watch(languageTypeListProvider).languageTypeList.first),
        ),
      ),
      // 再生と停止を行い、アニメーションの状態によってIconを差し替える
      floatingActionButton: FloatingActionButton(
        shape: const CircleBorder(),
        onPressed: _fadeAnimationController.pauseOrRestartAnimation,
        child: _fadeAnimationController.isAnimating ? const Icon(Icons.pause) : const Icon(Icons.play_arrow),
      ),
    );
  }
  // 表示するコンテンツは冒頭でEnumと紐づけたからいい感じに書けた
  Widget languageText(LanguageType type) {
    return Text(
      type.text,
      style: TextStyle(fontSize: 48, color: type.color),
      key: Key(type.text),
    );
  }
}

感想

Flutterのアニメーションの書き方は他にも色々あるみたいで、これを作るのにいくつか試して勉強になりました。
コンポーネント思考や状態管理の部分でSwiftUIに似ているなってところもありつつ、アニメーションは全然違ったのでとても新鮮でした。

Discussion