Development UI / UX

【Flutter】スワイプできるカード型UIを作ってみた(swipable_stack)

1. はじめに

Flutterで、カードを左右にスワイプして次のカードを表示したり、お気に入り登録したりできるUIを作成したので、サンプルコードとともにご紹介します。

完成系はこちらです。
右にスワイプするとカードがピンクに変わり、左にスワイプするとカードが白く変わる効果をつけています。

余談ですが、このようなUIはマッチングアプリになぞらえてTinderUIと呼ぶそうです。
最近ではマッチングアプリ以外のアプリでもこのようなUIを見かけることがたまにあります。

2. 使用したパッケージ

今回、実装には「swipable_stack」というパッケージを使用しました。

swipable_stack
https://pub.dev/packages/swipable_stack/versions

このパッケージ、2022年から更新が止まっているようなので選定してよいか悩みましたが、最低限の機能を簡単に実装することができそうだったので選定しました。
他に、メジャーなパッケージだと「flutter_card_swiper」というものもあるので、シチュエーションに応じて使ってみるといいかもしれません。

3. 実装してみる

事前準備

まずはターミナルからswipable_stackパッケージを入手。今回使用したswipable_stackのバージョンは2.0.0です。

flutter pub add swipable_stack

swipable_stackを使用したいクラスにインポート。

import 'package:swipable_stack/swipable_stack.dart';

実装

SwipableStackで使用するコントローラーを先に定義します。
公式ドキュメントのソースコードを参考にしました。

late final SwipableStackController _controller;

void _listenController() => setState(() {});

@override
void initState() {
  super.initState();
  _controller = SwipableStackController()..addListener(_listenController);
}

@override
void dispose() {
  super.dispose();
  _controller
    ..removeListener(_listenController)
    ..dispose();
}

Widgetツリーで「SwipableStack」を呼び出し、先に定義したコントローラーと、カードとして表示したいWidgetを設定します。

SwipableStack(
  controller: _controller, 
  builder: (context, properties) {
    return Stack(
      children: [
        // スワイプさせたいカード
        SwipeCard(),
      ],
    );
  },
);

基本的な実装はこれだけですが、次のセクションで、スワイプの挙動の制御や、スワイプ時のカードの色をカスタマイズしていきます。

4. カスタマイズする

スワイプ方向を制御する

基本的な実装をしただけでは、上下左右どこにでもスワイプできるようになってしまっています。
今回は左右にのみスワイプさせたいので、SwipableStackに下記のパラメータを追加します。

  // 検出するスワイプ方向を定義する
  detectableSwipeDirections: const {
    SwipeDirection.right,
    SwipeDirection.left,
  },
  // 垂直方向のスワイプを無効化
  allowVerticalSwipe: false,

スワイプしたときにカードの色を変える

スワイプしたときにカードの色を変え、文字やアイコンが表示されるようにします。
今回は以下のような実装を行います。

  • 右にスワイプすると、カードがピンクに変わり、「食べたい!」テキストと、ハートアイコンが表示される。
  • 左にスワイプすると、カードが白色に変わり、「うーん」テキストと、Noアイコンが表示される。
  • カードが20%スワイプされると色がつき始め、進行度に応じて色が濃くなる。

色のつき始めを20%としている理由としては、スワイプを途中でやめると、カードがバウンドするようなアニメーションがついているので、バウンドしたときに色がちらつかないようにするためです。(薄い色であれば気にならないと思います)

SwipableStackに下記のパラメータを追加します。

overlayBuilder: (context, properties) {
  // スワイプの進行度を取得(0.0 ~ 1.0)
  final swipeProgress = properties.swipeProgress.abs();
  // 20%のスワイプを閾値とする
  const threshold = 0.2;
  // 閾値を超えた分の進行度を計算(0.0 ~ 1.0)
  final opacity = ((swipeProgress - threshold) / (1 - threshold)).clamp(0.0, 1.0);
  final isRight = properties.direction == SwipeDirection.right;
  return Stack(
    children: [
      Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(12),
          color: (isRight ? Colors.pinkAccent.shade100 : Colors.grey.shade100)
              .withValues(alpha: 0.9 * opacity),
        ),
      ),
      Center(
        child: Opacity(
          opacity: opacity,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SizedBox(
                height: 70,
                width: 70,
                child: Icon(
                  isRight ? CupertinoIcons.heart_fill : CupertinoIcons.nosign,
                  color: isRight ? Colors.white : Colors.black,
                ),
              ),
              Text(
                isRight ? '食べたい!' : 'うーん',
                style: isRight
                    ? TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)
                    : TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
              ),
            ],
          ),
        ),
      ),
    ],
  );
},

overlayBuilderでは、returnに設定したWidgetをカードの上に重ねて表示することができます。
また、overlayBuilderのpropertiesからは下記の情報を取得できるため、スワイプ方向やスワイプの進行度から、色の濃さを計算できます。

  • swipeProgress: スワイプの進行度(0.0 〜 1.0)
  • direction: スワイプの方向

スワイプ時の処理を追加する

スワイプしたときにお気に入りに追加したり、何か処理をさせたい場合には下記のパラメータを追加します。

onSwipeCompleted: (index, direction) {
  if (direction == SwipeDirection.right) {
    print('右にスワイプしました');
  } else {
    print('左にスワイプしました');
  }
},

スワイプの方向をdirectionで取得することができるので、処理を書き分けます。
スワイプが全てのカード分完了したら…というような処理もここに書けます。

その他のパラメータ

カードをスワイプしたときに領域外が切れないようにする

stackClipBehaviour: Clip.none,

画面の何%までスワイプするとスワイプが完了するか(例:80%)

horizontalSwipeThreshold: 0.8,

スワイプするカードの数(これを指定しないと無限ループする)

itemCount: cardItems.length,  // 渡したリストのアイテム数

 

5. おわりに

SwipableStackはメンテされてないこともあり、情報が少ないパッケージですが、最低限のカスタマイズ性で簡単にスワイプ型UIを実現できる有用なパッケージでした!
ぜひ使ってみてください。

6. サンプルコード全文

main.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:swipable_stack/swipable_stack.dart';
import 'package:swipe_practice/swipe_button.dart';
import 'package:swipe_practice/swipe_card.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'swipable_stackの練習'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  late final SwipableStackController _controller;

  void _listenController() => setState(() {});

  @override
  void initState() {
    super.initState();
    _controller = SwipableStackController()..addListener(_listenController);
  }

  @override
  void dispose() {
    super.dispose();
    _controller
      ..removeListener(_listenController)
      ..dispose();
  }

  @override
  Widget build(BuildContext context) {
    final List<Map<String, String>> cardItems = [
      {
        'imagePath': 'assets/hamburger.jpg',
        'title': 'ハンバーガー',
      },
      {
        'imagePath': 'assets/kaisendon.jpg',
        'title': '海鮮丼',
      },
      {
        'imagePath': 'assets/ramen.jpg',
        'title': 'ラーメン',
      },
    ];

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            SizedBox(
              width: MediaQuery.of(context).size.width * 0.9,
              height: MediaQuery.of(context).size.height * 0.65,
              child:
              SwipableStack(
                itemCount: cardItems.length,
                controller: _controller,
                // 検出するスワイプ方向を定義する
                detectableSwipeDirections: const {
                  SwipeDirection.right,
                  SwipeDirection.left,
                },
                // 垂直方向のスワイプを無効化
                allowVerticalSwipe: false,
                // カードをスワイプしたときに領域外が切れないようにする
                stackClipBehaviour: Clip.none,
                // 画面の80%までスワイプするとスワイプが完了する
                horizontalSwipeThreshold: 0.8,
                // スワイプ時に上に重なって表示される色/アイコン/テキスト
                overlayBuilder: (context, properties) {
                  // スワイプの進行度を取得(0.0 ~ 1.0)
                  final swipeProgress = properties.swipeProgress.abs();
                  // 20%のスワイプを閾値とする
                  const threshold = 0.2;
                  // 閾値を超えた分の進行度を計算(0.0 ~ 1.0)
                  final opacity = ((swipeProgress - threshold) / (1 - threshold)).clamp(0.0, 1.0);
                  final isRight = properties.direction == SwipeDirection.right;
                  return Stack(
                    children: [
                      Container(
                        decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(12),
                          color: (isRight ? Colors.pinkAccent.shade100 : Colors.grey.shade100)
                              .withValues(alpha: 0.9 * opacity),
                        ),
                      ),
                      Center(
                        child: Opacity(
                          opacity: opacity,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              SizedBox(
                                height: 70,
                                width: 70,
                                child: Icon(
                                  isRight ? CupertinoIcons.heart_fill : CupertinoIcons.nosign,
                                  color: isRight ? Colors.white : Colors.black,
                                ),
                              ),
                              Text(
                                isRight ? '食べたい!' : 'うーん',
                                style: isRight
                                    ? TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)
                                    : TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ],
                  );
                },
                onSwipeCompleted: (index, direction) {
                  if (direction == SwipeDirection.right) {
                    print('右にスワイプしました');
                  } else {
                    print('左にスワイプしました');
                  }
                },
                builder: (context, properties) {
                  final itemIndex = properties.index % cardItems.length;
                  return Stack(
                    children: [
                      SwipeCard(
                        imagePath: cardItems[itemIndex]['imagePath']!,
                        title: cardItems[itemIndex]['title']!,
                      ),
                    ],
                  );
                },
              ),
            ),
            Container(
              margin: const EdgeInsets.only(top: 7),
              child: SwipeButton(
                onSwipe: (direction) {
                  _controller.next(swipeDirection: direction);
                },
              ),
            )
          ],
        ),
      ),
    );
  }
}

swipe_card.dart

import 'package:flutter/material.dart';

class SwipeCard extends StatelessWidget {
  const SwipeCard({
    super.key,
    required this.imagePath,
    required this.title,
  });

  final String imagePath;
  final String title;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        color: Colors.white,
      ),
      clipBehavior: Clip.hardEdge,
      child: Stack(
        fit: StackFit.expand,
        children: [
          Image.asset(
            imagePath,
            fit: BoxFit.cover,
          ),
          Positioned(
            bottom: 16,
            left: 16,
            child: Text(
              title,
              style: const TextStyle(
                color: Colors.white,
                fontSize: 30,
                fontWeight: FontWeight.bold,
                shadows: [
                  Shadow(
                    offset: Offset(1, 1),
                    blurRadius: 8,
                    color: Colors.black54,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

swipe_button.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:swipable_stack/swipable_stack.dart';

class SwipeButton extends StatelessWidget {
  const SwipeButton({
    super.key,
    required this.onSwipe,
  });

  final ValueChanged onSwipe;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        Card(
          elevation: 8,
          shape: const CircleBorder(),
          child: SizedBox(
            height: 80,
            width: 80,
            child: ElevatedButton(
              onPressed: () {
                onSwipe(SwipeDirection.left);
              },
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.white,
                shape: const CircleBorder(),
                padding: EdgeInsets.zero,
              ),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                  Icon(
                    CupertinoIcons.nosign,
                    color: Colors.black,
                    size: 30,
                  ),
                  SizedBox(height: 4),
                  Text(
                    'うーん',
                    style: TextStyle(
                      color: Colors.black,
                      fontSize: 12,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
        Card(
          elevation: 8,
          shape: const CircleBorder(),
          child: SizedBox(
            height: 80,
            width: 80,
            child: ElevatedButton(
              onPressed: () {
                onSwipe(SwipeDirection.right);
              },
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.pinkAccent,
                shape: const CircleBorder(),
                padding: EdgeInsets.zero,
              ),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                  Icon(
                    CupertinoIcons.heart_fill,
                    color: Colors.white,
                    size: 30,
                  ),
                  SizedBox(height: 4),
                  Text(
                    '食べたい!',
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 12,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ],
    );
  }
}

Latest最新記事

Popular人気の記事