はじめに
こんにちは!Flutterでアプリ開発を行なっている者です!
プライベートでTinder風のUIを実現すべく試行錯誤したので、メモを兼ねて共有したいと思います。
実現したいUIについて
Tinder風、と言われてもピンとこない方もいらっしゃるかと思うので、実現したいUIを紹介します。
簡単にいうと、積み重なったカードを上下左右にスワイプして次のカードを表示するUIとなります。それぞれのスワイプ方向に応じて、何らかのアクション(好き・嫌いなど)を行うことができます。
上記のUIを実現するPackageはそこそこ提供されているが、痒いところに手が届かないものが多い。。。
https://fluttergems.dev/tinder-swipe-cards/
完成品
実装
・ 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),
],
);
},
),
),
);
}
}