Flutterで2つの要素をアニメーションで切り替える

## モチベーション
Flutterで同じエリアの2つの要素を切り替えるということをしたい場合、まず最初に思いつくのはStatefulWidgetにフラグを持たせて切り替えることだと思います.

今回は、その切り替えにアニメーション効果をつけることができる`AnimatedSwitcher`ウィジェットの使い方とそこでしているすtransitionについて解説していきます

https://api.flutter.dev/flutter/widgets/AnimatedSwitcher-class.html

### State切り替えだけのサンプル
```dart:main.dart
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  bool showCircle = false;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: showCircle
            ? const SizedBox(
                key: ValueKey('circle'),
                width: 100,
                child: AspectRatio(
                  aspectRatio: 1,
                  child: DecoratedBox(
                    decoration: BoxDecoration(
                      color: Colors.red,
                      shape: BoxShape.circle,
                    ),
                  ),
                ),
              )
            : const SizedBox(
                width: 100,
                child: AspectRatio(
                  aspectRatio: 1,
                  child: DecoratedBox(
                    decoration: BoxDecoration(
                      color: Colors.blue,
                      shape: BoxShape.rectangle,
                    ),
                  ),
                ),
              ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            showCircle = !showCircle;
          });
        },
        child: const Icon(Icons.change_circle),
      ),
    );
  }
}
```
### 上記コードの動作

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/ccd14ccd-45c1-42cc-fbf8-8dd192786d57.gif" width="280">

もちろんこれでもいいにはいいのですが、味気ないですね。
早速AnimatedSwitcherを適応してみましょう

```dart
child: AnimatedSwitcher(
  duration: const Duration(milliseconds: 500),
  reverseDuration: const Duration(milliseconds: 500),
  transitionBuilder: (child, animation) {
    return FadeTransition(
      opacity: animation,
      child: child,
    );
  },
  child: showCircle
      ? const SizedBox(
          key: ValueKey('circle'),
          width: 100,
          child: AspectRatio(
            aspectRatio: 1,
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.red,
                shape: BoxShape.circle,
              ),
            ),
          ),
        )
      : const SizedBox(
          width: 100,
          child: AspectRatio(
            aspectRatio: 1,
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.blue,
                shape: BoxShape.rectangle,
              ),
            ),
          ),
        ),
),
```

# AnimatedSwitcherの基本的なパラメーター
- duration: 新しい child の値から古い値への切り替えのDurationの設定
- reverseDuration: 古い child の値から新しい値への切り替えのDurationの設定
- transitionBuilder: 後述しますが、どのような変化をさせるか設定できます

# AnimatedSwitcherの注意点

AnimatedSwitcherは、childのウィジェットの切り替わりを検知してtransitionBuilderが発動します。
では、何を持って「新しいウィジェットに切り替わった」を判定しているかというと、Flutterフレームワークにある`canUpdate`によって識別をしています。

```dart:frameworks.dart

  /// Whether the `newWidget` can be used to update an [Element] that currently
  /// has the `oldWidget` as its configuration.
  ///
  /// An element that uses a given widget as its configuration can be updated to
  /// use another widget as its configuration if, and only if, the two widgets
  /// have [runtimeType] and [key] properties that are [operator==].
  ///
  /// If the widgets have no key (their key is null), then they are considered a
  /// match if they have the same type, even if their children are completely
  /// different.
  static bool canUpdate(Widget oldWidget, Widget newWidget) {
    return oldWidget.runtimeType == newWidget.runtimeType
        && oldWidget.key == newWidget.key;
  }
```

  そのため、先ほどの例では片方にだけValueKeyの設定をしていましたが、ウィジェットのキーが同じかつ同じ型のウィジェットを使っている場合、は気をつけないといけません。任意のキーを設定するか、UniqueKey()などを用いてこの問題は回避できます。
  
```dart
  child: showCircle
    ? SizedBox(
        key: UniqueKey(),
        width: 100,
        child: const AspectRatio(
          aspectRatio: 1,
          child: DecoratedBox(
            decoration: BoxDecoration(
              color: Colors.red,
              shape: BoxShape.circle,
            ),
          ),
        ),
      )
    : SizedBox(
        key: UniqueKey(),
        width: 100,
        child: const AspectRatio(
          aspectRatio: 1,
          child: DecoratedBox(
            decoration: BoxDecoration(
              color: Colors.blue,
              shape: BoxShape.rectangle,
            ),
          ),
        ),
      ),
```

この辺りはドキュメントにも記載されています

https://api.flutter.dev/flutter/widgets/AnimatedSwitcher/child.html

> The child is considered to be "new" if it has a different type or Key (see Widget.canUpdate).
childの型が違う場合、またはキーが違う場合に「新しい」とみなされる

ということですね。
この知識があると、先ほどのdurationとreverseDurationの説明で言っていた「新しい」「古い」の定義がわかると思います。

> - duration: 新しい child の値から古い値への切り替えのDurationの設定
> - reverseDuration: 古い child の値から新しい値への切り替えのDurationの設定

ちなみに、durationはrequiredですがreverseDurationは任意パラメーターです。reverseDurationはdurationと値が異なる場合のみ参照されます。

以下のように設定を書き換えてみてください

```diff_dart
- duration: const Duration(milliseconds: 500),
- reverseDuration: const Duration(milliseconds: 500),
+ duration: const Duration(milliseconds: 1500),
+ reverseDuration: const Duration(milliseconds: 100),
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/6d03b5b3-2b79-04fa-3054-a04cae806fe8.gif" width="280">

どうでしょう？新しく出て来る`duration`は少しゆっくり目になっていて、消えていく`reverseDuration`はそれより早いタイミングで消えるようになったと思います。


# Transitonの種類
さて、`transitionBuilder`には何が設定できるのでしょうか？
答えは、Flutterの`flutter/lib/src/widgets/transitions.dart`に定義してあるクラスです。それぞれ挙動を見ていきましょう

durationは元通り500msでやっていきます
```dart
duration: const Duration(milliseconds: 500),
reverseDuration: const Duration(milliseconds: 500),
```

それでは、各Transitionクラスの挙動を見ていきましょう

### SlideTransition
```dart
transitionBuilder: (child, animation) {
  return SlideTransition(
    position: Tween<Offset>(
      begin: const Offset(0, 1),
      end: const Offset(0, 0),
    ).animate(animation),
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/df892101-e5f8-e10c-25e0-166f12e8e2d2.gif" width="250" >


### MatrixTransition
```dart
transitionBuilder: (child, animation) {
  return MatrixTransition(
    onTransform: (progress) {
      return Matrix4.identity()
        ..setEntry(3, 2, 0.001)
        ..rotateX(3.14 * progress);
    },
    animation: animation,
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/689bd7df-ec4c-f6d7-1ae9-60fddfbf1c96.gif" width="250" >


### ScaleTransition
```dart
transitionBuilder: (child, animation) {
  return ScaleTransition(
    scale: animation,
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/5748a71e-741d-a046-fb73-d31ff478b950.gif" width="250" >

### FadeTransition
```dart
transitionBuilder: (child, animation) {
  return FadeTransition(
    opacity: animation,
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/7f64f772-38b3-d961-316d-b6d0eeea0a2d.gif" width="250" >

### SliverFadeTransition
- FadeTransitionのSliverバージョンなので省略します

### RotationTransition
```dart
transitionBuilder: (child, animation) {
  return RotationTransition(
    turns: animation,
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/91e2ce1b-ca92-d7e7-cec3-5b05d002d748.gif" width="250" >


### SizeTransition
```dart
transitionBuilder: (child, animation) {
  return SizeTransition(
    sizeFactor: animation,
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/7ddde1a6-476f-03bd-0465-5d937893f092.gif" width="250" >

### PositionedTransition
```dart
transitionBuilder: (child, animation) {
  return PositionedTransition(
    rect: RelativeRectTween(
      begin: RelativeRect.fromSize(
        const Rect.fromLTWH(100, 100, 20, 20),
        const Size(10, 10),
      ),
      end: RelativeRect.fromSize(
        const Rect.fromLTWH(0, 0, 20, 20),
        const Size(10, 10),
      ),
    ).animate(animation),
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/6f0bf50a-69d1-ed8e-4e9e-b485152bc2d0.gif" width="250" >


### RelativePositionedTransition
```dart
transitionBuilder: (child, animation) {
  return RelativePositionedTransition(
    size: const Size(20, 20),
    rect: RectTween(
      begin: const Rect.fromLTWH(0, 0, 100, 0),
      end: const Rect.fromLTWH(0, 0, 0, 100),
    ).animate(animation),
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/cbea118f-3492-7044-bf6e-4f546c1a6a35.gif" width="250" >

### DecoratedBoxTransition
```dart
transitionBuilder: (child, animation) {
  return DecoratedBoxTransition(
    decoration: DecorationTween(
      begin: const BoxDecoration(
        color: Colors.blue,
        shape: BoxShape.rectangle,
      ),
      end: const BoxDecoration(
        color: Colors.red,
        shape: BoxShape.circle,
      ),
    ).animate(animation),
    child: child,
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/23481d15-c89e-51da-4e31-d466d847648e.gif" width="250" >

### AlignTransition
```dart
transitionBuilder: (child, animation) {
  return AlignTransition(
    alignment: Tween<Alignment>(
      begin: const Alignment(0, -1),
      end: const Alignment(0, 0),
    ).animate(animation),
    child: SizeTransition(
      sizeFactor: animation,
      child: child,
    ),
  );
},
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/ec239740-13ba-fcf7-8511-6fb1dec4059b.gif" width="250" >


### DefaultTextStyleTransition
これはクラス名の通りテキストのアニメーションができます
```dart
child: AnimatedSwitcher(
  duration: const Duration(milliseconds: 500),
  reverseDuration: const Duration(milliseconds: 500),
  transitionBuilder: (child, animation) {
    return DefaultTextStyleTransition(
      style: animation.drive(
        TextStyleTween(
          begin: const TextStyle(
            color: Colors.black,
            fontSize: 12,
          ),
          end: const TextStyle(
            color: Colors.white,
            fontSize: 36,
          ),
        ),
      ),
      child: child,
    );
  },
  child: showCircle
      ? SizedBox(
          key: UniqueKey(),
          width: 100,
          child: const AspectRatio(
            aspectRatio: 1,
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.red,
                shape: BoxShape.circle,
              ),
              child: Text('Hello'),
            ),
          ),
        )
      : SizedBox(
          key: UniqueKey(),
          width: 100,
          child: const AspectRatio(
            aspectRatio: 1,
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.blue,
                shape: BoxShape.rectangle,
              ),
              child: Text('Hello'),
            ),
          ),
        ),
),
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/96971a25-99b3-410a-a9da-827723e42839.gif" width="280" >



### ListenableBuilder
こちらはAnimatedWidgetクラスを継承しているとはいえ、今回の趣旨とは逸れるので同様の説明は省きます。
Listenableとある通り、何かの変更を検知してウィジェットをリビルドするものになります。
StatefulWidgetにしてしまうと画面全体がリビルド範囲になってしまったりするものを、このListenableBuilderを用いるとStatelessWidgetの中でリビルドができる＋リビルド領域を絞り込めると言ったメリットがあります。

しかし、昨今のライブラリ事情から行くとRiverpod（Provider）とConsumerウィジェットやselectによる参照が主流のためこのクラスの出番が少ないのではないかと思いました。一応例だけ示しておきます。

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

void main() {
  runApp(MyApp());
}

// カウンターモデルを定義（ChangeNotifierを継承）
class CounterModel extends ChangeNotifier {
  int _counter = 0;

  int get counter => _counter;

  void increment() {
    _counter++;
    notifyListeners(); // リスナーに通知
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  final CounterModel _model = CounterModel();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('ListenableBuilder Example')),
      body: Center(
        // ListenableBuilderを使用
        child: ListenableBuilder<CounterModel>(
          listenable: _model,
          builder: (context, model, child) {
            // ここでモデルの状態に基づいてUIを更新
            return Text('You have pushed the button ${model.counter} times');
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _model.increment(),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}
```