14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-07-11

はじめに

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

実現したいUIについて

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

完成品

今回実装したものはこちらになります。
画面収録_2023-07-11_19_12_17_AdobeExpress.gif

実装

・ cards_stack_widget.dart

Stackで複数の画像を積み重ねることでTinder風UIを実現しています。
本クラスでスワイプ後のアニメーションやボタンタップ後のスワイプアニメーションを実現しています。
アニメーションについては、PositionedTransitionとRotationTransitionを用いて実現しています。

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 {
                // 2層目以降のカード
                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を使って実現しています。

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),
              ],
            );
          },
        ),
      ),
    );
  }
}

14
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?