FlutterでTinder風UIを実装してみた

# はじめに
こんにちは！Flutterでアプリ開発を行なっている者です！
プライベートでTinder風のUIを実現すべく試行錯誤したので、メモを兼ねて共有したいと思います。

# 実現したいUIについて
Tinder風、と言われてもピンとこない方もいらっしゃるかと思うので、実現したいUIを紹介します。
簡単にいうと、積み重なったカードを上下左右にスワイプして次のカードを表示するUIとなります。それぞれのスワイプ方向に応じて、何らかのアクション（好き・嫌いなど）を行うことができます。
上記のUIを実現するPackageはそこそこ提供されているが、痒いところに手が届かないものが多い。。。
https://fluttergems.dev/tinder-swipe-cards/

# 完成品
今回実装したものはこちらになります。
<img width="300" alt="画面収録_2023-07-11_19_12_17_AdobeExpress.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2949811/70c7867a-b636-bcbd-5e9f-c6166c081990.gif">

# 実装
#### ・ cards_stack_widget.dart
Stackで複数の画像を積み重ねることでTinder風UIを実現しています。
本クラスでスワイプ後のアニメーションやボタンタップ後のスワイプアニメーションを実現しています。
アニメーションについては、PositionedTransitionとRotationTransitionを用いて実現しています。
```dart
class CardsStackWidget extends StatefulWidget {
  const CardsStackWidget({
    super.key,
    required this.controller,
    required this.onSwiped,
  });

  // 外部からスワイプを制御するためのController
  final CardsStackWidgetController? controller;
  // スワイプ完了後に呼ばれるCallBack
  final void Function({
    required Swipe swipe,
    required int index,
  }) onSwiped;

  @override
  State<CardsStackWidget> createState() => _CardsStackWidgetState();
}

class _CardsStackWidgetState extends State<CardsStackWidget>
    with SingleTickerProviderStateMixin {
  // Swipe Objectのステータス監視用のValueNotifier
  ValueNotifier<SwipeData> swipeNotifier = ValueNotifier(
    const SwipeData(swipe: Swipe.none, deltaX: 0, deltaY: 0, turns: 0),
  );
  // アニメーション制御用のController
  late final AnimationController _animationController;

  List<SnapResponse> snaps = [];

  @override
  void initState() {
    super.initState();

    final controller = widget.controller;
    if (controller != null) {
      controller
        ..listener = _triggerSwipe
        ..addItem = _appendItem;
    }

    // AnimationController 初期化
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 500),
      vsync: this,
    );
    // Animationの状態を監視
    _animationController.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        if (swipeNotifier.value.swipe != Swipe.none) {
          widget.onSwiped(
            swipe: swipeNotifier.value.swipe,
            index: snaps.length,
          );
          // Animation完了後にStackに積まれた一番上の画像を削除する
          snaps.removeLast();
        }
        _animationController.reset();
        swipeNotifier.value =
            const SwipeData(swipe: Swipe.none, deltaX: 0, deltaY: 0, turns: 0);
      }
    });
  }

  @override
  void dispose() {
    super.dispose();
    swipeNotifier.dispose();
    _animationController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // カードのサイズ
    final height = MediaQuery.of(context).size.height * 4 / 7;
    final width = MediaQuery.of(context).size.width * 4 / 5;
    return ClipRRect(
      borderRadius: BorderRadius.circular(10),
      child: ValueListenableBuilder(
        valueListenable: swipeNotifier,
        builder: (context, SwipeData swipeData, _) => Stack(
          clipBehavior: Clip.none,
          alignment: Alignment.center,
          children: snaps.asMap().entries.map(
            (entry) {
              // 一番上のSnap
              if (entry.key == snaps.length - 1) {
                // 移動アニメーションの終了座標
                var rect = Rect.fromLTWH(0, 0, height, width);
                var turnsEnd = 0.0;
                // ドラッグ終了時の移動アニメーションの時間
                var moveAnimationInterval = 1.0;
                // ドラッグ終了時の回転アニメーションの時間
                var rotationAnimationInterval = 0.8;
                switch (swipeData.swipe) {
                  // スワイプした方向に応じて以下を調整する
                  // ・アニメーションの時間
                  // ・画像の移動量
                  // ・画像の回転角度
                  case Swipe.none:
                    moveAnimationInterval = 0.15;
                    rotationAnimationInterval = 0.15;
                    break;
                  case Swipe.top:
                    rect = Rect.fromLTWH(
                      0,
                      -MediaQuery.of(context).size.height * 0.8,
                      height,
                      width,
                    );
                    break;
                  case Swipe.right:
                    rect = Rect.fromLTWH(
                      MediaQuery.of(context).size.width * 1.2,
                      0,
                      height,
                      width,
                    );
                    turnsEnd = swipeData.turns + (0.05);
                    break;
                  case Swipe.left:
                    rect = Rect.fromLTWH(
                      -MediaQuery.of(context).size.width * 1.2,
                      0,
                      height,
                      width,
                    );
                    turnsEnd = swipeData.turns + (-0.05);
                    break;
                }
                return PositionedTransition(
                  // 現在の座標から指定された方向に移動アニメーションを実行する
                  rect: RelativeRectTween(
                    begin: RelativeRect.fromSize(
                      Rect.fromLTWH(
                        swipeData.deltaX,
                        swipeData.deltaY,
                        height,
                        width,
                      ),
                      Size(height, width),
                    ),
                    end: RelativeRect.fromSize(
                      rect,
                      Size(height, width),
                    ),
                  ).animate(
                    CurvedAnimation(
                      parent: _animationController,
                      curve: Interval(
                        0,
                        moveAnimationInterval,
                        curve: Curves.easeInOut,
                      ),
                    ),
                  ),
                  // 移動方向に応じた回転アニメーションを実行する
                  child: RotationTransition(
                    turns: Tween<double>(
                      begin: swipeData.turns,
                      end: turnsEnd,
                    ).animate(
                      CurvedAnimation(
                        parent: _animationController,
                        curve: Interval(
                          0,
                          rotationAnimationInterval,
                          curve: Curves.easeInOut,
                        ),
                      ),
                    ),
                    child: DragWidget(
                      snap: entry.value,
                      index: entry.key,
                      swipeNotifier: swipeNotifier,
                      onDragEnd: () {
                        _animationController.forward();
                        swipeNotifier.notifyListeners();
                      },
                    ),
                  ),
                );
              } else {
                // ２層目以降のカード
                return DragWidget(
                  snap: entry.value,
                  index: entry.key,
                  swipeNotifier: swipeNotifier,
                  onDragEnd: () {},
                );
              }
            },
          ).toList(),
        ),
      ),
    );
  }

  // Controller経由で外部からスワイプアニメーションを実行する際に呼ばれる
  void _triggerSwipe(Swipe swipe) {
    switch (swipe) {
      case Swipe.left:
        swipeNotifier.value = const SwipeData(
          swipe: Swipe.left,
          deltaX: 0,
          deltaY: 0,
          turns: 0,
        );
        _animationController.forward();
        break;
      case Swipe.right:
        swipeNotifier.value = const SwipeData(
          swipe: Swipe.right,
          deltaX: 0,
          deltaY: 0,
          turns: 0,
        );
        _animationController.forward();
        break;
      case Swipe.top:
        swipeNotifier.value = const SwipeData(
          swipe: Swipe.top,
          deltaX: 0,
          deltaY: 0,
          turns: 0,
        );
        _animationController.forward();
        break;
      case Swipe.none:
        break;
    }
  }

  // 外部から写真を追加する際に呼ばれる
  void _appendItem(List<SnapResponse> items) {
    setState(() {
      snaps.insertAll(0, items);
    });
  }
}

```
#### ・ drag_widget.dart
このWidgetで一番上の画像をドラッグできる様に制御しています。
ドラッグについてはDraggable Widgetを使って実現しています。
```dart
class DragWidget extends StatefulWidget {
  const DragWidget({
    super.key,
    required this.snap,
    required this.index,
    required this.swipeNotifier,
    required this.onDragEnd,
  });
  final SnapResponse snap;
  final int index;
  final ValueNotifier<SwipeData> swipeNotifier;
  final void Function() onDragEnd;

  @override
  State<DragWidget> createState() => _DragWidgetState();
}

class _DragWidgetState extends State<DragWidget> {
  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Draggable<int>(
        data: widget.index,
        feedback: Material(
          color: Colors.transparent,
          child: ValueListenableBuilder(
            valueListenable: widget.swipeNotifier,
            builder: (context, swipeData, _) {
              return RotationTransition(
                turns: AlwaysStoppedAnimation(widget.swipeNotifier.value.turns),
                child: Stack(
                  children: [
                    SnapCard(snap: widget.snap),
                  ],
                ),
              );
            },
          ),
        ),
        onDragUpdate: (DragUpdateDetails dragUpdateDetails) {
          // ドラッグした横の移動量を計算
          final deltaX =
              widget.swipeNotifier.value.deltaX + dragUpdateDetails.delta.dx;
          // ドラッグした縦の移動量を計算
          final deltaY =
              widget.swipeNotifier.value.deltaY + dragUpdateDetails.delta.dy;
          // 移動量の割合を画面の横幅から算出
          final ratioDistance = deltaX / MediaQuery.of(context).size.width;
          // 移動量の割合からカードの回転角度を変更する
          final turns = (360 * ratioDistance * 0.15) / 360;

          // 上スワイプ時の閾値を決定
          if (deltaY < MediaQuery.of(context).size.height / -6) {
            widget.swipeNotifier.value = SwipeData(
              swipe: Swipe.top,
              deltaX: deltaX,
              deltaY: deltaY,
              turns: turns,
            );
            return;
          }
          // 右スワイプ時の閾値を決定
          if (dragUpdateDetails.delta.dx > 0 &&
              deltaX > MediaQuery.of(context).size.width / 3.5) {
            widget.swipeNotifier.value = SwipeData(
              swipe: Swipe.right,
              deltaX: deltaX,
              deltaY: deltaY,
              turns: turns,
            );
            return;
          } else if (deltaX > MediaQuery.of(context).size.width / 3.5) {
            widget.swipeNotifier.value = SwipeData(
              swipe: Swipe.right,
              deltaX: deltaX,
              deltaY: deltaY,
              turns: turns,
            );
            return;
          }
          // 左スワイプ時の閾値を決定
          if (dragUpdateDetails.delta.dx < 0 &&
              deltaX < MediaQuery.of(context).size.width / -3.5) {
            widget.swipeNotifier.value = SwipeData(
              swipe: Swipe.left,
              deltaX: deltaX,
              deltaY: deltaY,
              turns: turns,
            );
            return;
          } else if (deltaX < MediaQuery.of(context).size.width / -3.5) {
            widget.swipeNotifier.value = SwipeData(
              swipe: Swipe.left,
              deltaX: deltaX,
              deltaY: deltaY,
              turns: turns,
            );
            return;
          }
          widget.swipeNotifier.value = SwipeData(
            swipe: Swipe.none,
            deltaX: deltaX,
            deltaY: deltaY,
            turns: turns,
          );
        },
        onDragEnd: (drag) {
          widget.onDragEnd();
        },
        childWhenDragging: const ColoredBox(
          color: Colors.transparent,
        ),
        child: ValueListenableBuilder(
          valueListenable: widget.swipeNotifier,
          builder: (BuildContext context, SwipeData swipeData, Widget? child) {
            return Stack(
              children: [
                // ドラッグされたいWidgetを設定
                SnapCard(snap: widget.snap),
              ],
            );
          },
        ),
      ),
    );
  }
}

```