🃏

Tinder風なUIをSwiftUIを利用して実装する際のアイデアと実装例紹介

に公開

1. はじめに

近年、Tinderのように左右へスワイプして次々とカードを処理するUIは、さまざまなアプリで採用される人気のデザインパターンとなっています。商品一覧からメニュー選択画面まで、カードを直感的に操作できる仕組みによって、ユーザー体験を大きく向上させることができます。

本記事では、SwiftUIを使って「Tinder風のカードスワイプ」を実装するサンプルを紹介します。添付ノートには、ドラッグジェスチャーやGeometryReaderを活用したスワイプ演出のポイントをまとめました。たとえば、スワイプの閾値(何%以上ドラッグされたらカードを“選択”とみなすか)や回転アニメーションの付け方など、実践的な工夫を交えつつ解説しています。

「ちょっと試してみたいけど、思ったより実装が複雑そう…」 と感じていた方は、ぜひ参考にしてみてください。

※なお、過去にUIKitで構築した事例も併せて掲載しています。

https://qiita.com/fumiyasac@github/items/c68b7ce812bf3ef48a67

2. 今回のサンプル概要&参考資料

Tinder風のUIを実装するうえで特に重要となる実装ポイントは、主に以下の3点です。

  1. カードを左右にスワイプさせる動き
  2. 配置されたカードの制御
  3. カードが消えたタイミングで行われる処理

さらに、画面表示時の処理やカードの細かいアニメーション調整なども、より洗練されたUIを実現するうえでこだわりたい要素となります。

UIKitでの実装と比較しても、実装内容次第では比較的シンプルに構築できる余地があり、View要素同士の関係性を把握しやすいという利点もあるかと思います。

2-1. サンプル概要:

【動作確認用コード】

公開しているGitHubリポジトリはこちらになります。

https://github.com/fumiyasac/TinderCartExampleSwiftUI

【画像キャプチャ】

キャプチャ内容① キャプチャ内容②
Home画面 検索画面

2-2. 参考記事:

https://medium.com/better-programming/swiftui-create-a-tinder-style-swipeable-card-view-283e257cb102

2-3. 実装方針図解ノート:

実装方針図解ノート1

実装方針図解ノート2

実装方針図解ノート3

3. コードから読み解く実装ポイント解説

ここからは、Tinder風UIのコンセプトを実際のソースコードを通じて紐解きながら、SwiftUIならではの実装ポイントを詳しく解説していきます。特に@StateDragGesture、カスタムビューを活用したカード管理の仕組みなどについて、「なぜこの書き方なのか」「どのように動作を制御しているのか」を理解しながら実装を進めることが重要です。

3-1. 想定アーキテクチャ:

【iOS17〜利用可能なObservation Frameworkを利用した形】

3-2. 実装コード内におけるポイント紹介:

【コンテンツ全体表示用のView要素】

ContentView.swift
import SwiftUI

// Tinderの様な動きをSwiftUIを利用して実現する
// 参考: https://github.com/bbaars/SwiftUI-Tinder-SwipeableCards

struct ContentView: View {

    // MARK: - ViewStateProvider

    private var contentViewStateProvider: ContentViewStateProvider

    // MARK: - Initializer

    init() {
        // ContentViewStateProviderの初期化する
        contentViewStateProvider = ContentViewStateProviderImpl()
    }

    // MARK: - Body

    var body: some View {

        NavigationStack {
            VStack {
                // ① タイトルヘッダー表示
                VStack {
                    Text("⭐️今日の気分に合う献立を選ぼう⭐️")
                        .font(.body)
                        .bold()
                        .foregroundColor(.secondary)
                        .lineLimit(1)
                    VStack(alignment: .leading) {
                        Text("今日の献立はどうしようか...🧐そんな時は気軽に写真と今日の気分にピッタリなものを選んでサッと決めてしまうのも良いでしょう🍽️")
                            .font(.caption)
                            .foregroundColor(.gray)
                            .lineLimit(2)
                            .padding(.top, 8.0)
                            .padding(.horizontal, 8.0)
                    }
                }
                .padding(.top, 16.0)
                .padding(.horizontal, 8.0)

                // ②-1. 画面上にカードが1枚も存在しない場合は、再度配置をするボタンを配置する
                if contentViewStateProvider.foodMenus.isEmpty {

                    HStack {
                        Spacer()
                        Button(action: {
                            contentViewStateProvider.fetchFoodMenus()
                        }, label: {
                            Text("再度献立カードの一覧を表示する")
                                .font(.body)
                                .foregroundColor(.black)
                                .background(.white)
                                .frame(width: 280.0, height: 48.0)
                                .cornerRadius(24.0)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 24.0)
                                        .stroke(Color(.black), lineWidth: 1.0)
                                )
                        })
                        Spacer()
                    }
                    .padding(.vertical, 16.0)
                
                // ②-2. 画面上にカードが少なくとも1枚存在する場合は、カードを重ねる様に表示する
                } else {

                    // GeometryReaderを利用して、デバイス幅を元に要素配置に必要な値を算出する
                    GeometryReader { proxy in
                        // 👉 1つあたりの表示要素を重ねるためにZStackを用いる
                        ZStack {
                            ForEach(contentViewStateProvider.foodMenus, id: \.self) { foodMenuEntity in
                                Group {
                                    // 取得できたデータの順番にカード用View要素を重ねて配置する
                                    SwipableCardView(foodMenuEntity: foodMenuEntity, removeAction: { removeTargetFoodMenuEntity in
                                        contentViewStateProvider.removeFoodMenu(id: removeTargetFoodMenuEntity.id)
                                    })
                                    .animation(.spring, value: contentViewStateProvider.foodMenus)
                                    .frame(width: getCardWidth(proxy: proxy, id: foodMenuEntity.id), height: 350.0)
                                    .offset(x: 16.0, y: getCardOffset(proxy: proxy, id: foodMenuEntity.id))
                                }
                            }
                        }
                    }
                    .padding(.top, 16.0)
                }
 
                // ③ 上寄せにするためのSpacer
                Spacer()
            }
            .onFirstAppear {
                contentViewStateProvider.fetchFoodMenus()
            }
            .navigationBarTitle("Today's Dinner Selection")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
    
    // MARK: - Private Function

    // スワイプできるカード要素の幅を算出する
    // 👉 SwipableCardView ~ .frameの箇所でこの計算値を利用しています
    private func getCardWidth(proxy: GeometryProxy, id: Int) -> CGFloat {
        let offset: CGFloat = getCardOffset(proxy: proxy, id: id)
        let originCardWidth: CGFloat = proxy.size.width - 36.0
        // 👉 負数になってはいけないWarningが発生したので、値に絶対値を利用する
        // 参考: https://ios-docs.dev/invalid-frame-dimension/
        return abs(originCardWidth - offset)
    }

    // スワイプできるカード要素のオフセット値を算出する
    // 👉 SwipableCardView ~ .offsetの箇所でこの計算値を利用しています
    private func getCardOffset(proxy: GeometryProxy, id: Int) -> CGFloat {
        return CGFloat(contentViewStateProvider.foodMenus.count - 1 - id) * 6.0
    }
}

以下に、添付コードにおける主なポイントをまとめました。

1.ContentViewStateProviderによるデータ管理:

  • contentViewStateProviderfoodMenus(献立カードのデータリスト)を保持し、フェッチ・削除といった操作を一元管理しています。
  • onFirstAppearイベントで自動的に献立情報を取得(fetchFoodMenus())する仕組みになっています。

2.カードが空かどうかの条件分岐:

  • foodMenus.isEmptyのときは「再度献立カードを表示する」ボタンを表示し、ユーザーが明示的にデータを再取得できるようにしています。
  • 1枚以上ある場合は、実際のスワイプ可能なカードの表示を行います。

3.GeometryReaderZStackを活用したカード表示:

  • GeometryReaderで画面サイズを取得し、ZStack上に複数のカードを重ねるように配置。
  • ForEachfoodMenus内のデータを回してSwipableCardViewを生成し、重なったUIを実現しています。

4.SwipableCardViewremoveAction連携:

  • 各カードにはremoveActionを実装しており、スワイプ操作による削除イベントを受けてcontentViewStateProvider.removeFoodMenu(id:)を呼び出すことで、配列から対象カードが除外されます。
  • .animation(.spring, value: contentViewStateProvider.foodMenus)を使ってカードの追加・削除にアニメーションが付与されます。

5.カードサイズ・オフセットの計算ロジック:

  • getCardWidth(proxy:id:)getCardOffset(proxy:id:)で、カードの幅や縦方向のオフセットを動的に算出。
  • カードがスタックされるように微妙にずらしながら配置し、ユーザーが複数のカードを重ねて認識できるようになっています。

6.UIの構成とレイアウト:

  • タイトルや説明文を先頭に配置したうえで、カード群またはボタンを表示し、最後にSpacer()を設置することで全体を上寄せ。
  • ナビゲーションバーのタイトル設定 (navigationBarTitle) で、画面が1つの機能ブロックとしてまとまっています。

これらの点を通じて、「配列のデータをカードとしてZStackに重ね、スワイプ操作で削除する」 という Tinder風UI を、ContentViewStateProviderを中心としたデータ管理と GeometryReaderZStackを活用したレイアウト制御によって実現しているのが大きな特徴です。

【Swipe可能なCard型のView要素】

SwipableCardView.swift
import SwiftUI

struct SwipableCardView: View {

    // MARK: - Property

    // スワイプ動作時のStatusを保持する
    @State private var swipeStatus: SwipeStatus = .none

    // Drag処理時(ここではスワイプ動作時と同義)の変化量を保持する
    @State private var swipeOffset: CGSize = .zero

    private let foodMenuEntity: FoodMenuEntity

    // 配置元の画面から削除する際に実行したいActionのClosure
    private let removeAction: (_ foodMenuEntity: FoodMenuEntity) -> Void

    // 画面幅を基準としたスワイプ移動量の割合
    // 👉 この割合がCardを画面上から削除する基準となる
    private let thresholdActionPercentage: CGFloat = 0.45

    // 👉 この割合がSwipe動作中にメッセージを表示する基準となる
    private let thresholdMessagePercentage: CGFloat = 0.12

    // MARK: - Enum

    // Swipe時の状態を表現したEnum
    private enum SwipeStatus: Int {
        case addToCart, notSelect, none
    }

    // MARK: - Initializer

    init(
        foodMenuEntity: FoodMenuEntity,
        removeAction: @escaping (_ foodMenuEntity: FoodMenuEntity) -> Void
    ) {
        self.foodMenuEntity = foodMenuEntity
        self.removeAction = removeAction
    }

    // MARK: - Body

    var body: some View {

        // 👉 画面幅を基準としてスワイプ変化量算出のために、GeometryReaderを画面全体に適用する
        GeometryReader { proxy in
            
            // 要素全体を囲むVStack
            VStack(alignment: .leading) {
                
                // ① サムネイル画像
                // 👉 サムネイル画像の上にメッセージを表示したいので、ZStackで囲んでいる
                ZStack(alignment: swipeStatus == .addToCart ? .topLeading : .topTrailing) {
                    
                    // メインで表示するサムネイル画像
                    Image(foodMenuEntity.imageName)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: proxy.size.width, height: proxy.size.height)
                        .clipped()

                    // 👉 右Swipe時に左上にメッセージを表示する
                    if swipeStatus == .addToCart {
                        
                        Text("🛒今日はコレ!")
                            .font(.headline)
                            .padding()
                            .foregroundColor(Color.green)
                            .overlay(
                                RoundedRectangle(cornerRadius: 0.0)
                                    .stroke(Color.green, lineWidth: 3.0)
                            )
                            .background(.white)
                            .padding(24.0)
                        
                    // 👉 左Swipe時に左上にメッセージを表示する
                    } else if swipeStatus == .notSelect {
                        
                        Text("🙅違うかなぁ…")
                            .font(.headline)
                            .padding()
                            .foregroundColor(Color.red)
                            .overlay(
                                RoundedRectangle(cornerRadius: 0.0)
                                    .stroke(Color.red, lineWidth: 3.0)
                            )
                            .background(.white)
                            .padding(24.0)

                    // 👉 スワイプ時以外は何も表示しない
                    } else {
                        EmptyView()
                    }
                }
                // ② サムネイル画像に関連する情報を表示する
                HStack {
                    VStack(alignment: .leading, spacing: 8.0) {
                        Text(foodMenuEntity.name)
                            .font(.callout)
                            .bold()
                            .padding(.top, 12.0)
                        VStack {
                            Text(foodMenuEntity.caregory)
                                .font(.caption)
                                .bold()
                                .foregroundStyle(.white)
                                .padding(4.0)
                        }
                        .overlay(
                            RoundedRectangle(cornerRadius: 0.0)
                                    .stroke(Color.orange, lineWidth: 1.0)
                        )
                        .background(.orange)
                        .padding(.top, 4.0)
                            
                    }
                    Spacer()
                    HStack {
                        Button(action: {}, label: {
                            Image(systemName: "star.fill")
                                .foregroundColor(.yellow)
                        })
                        Button(action: {}, label: {
                            Image(systemName: "cart.fill")
                                .foregroundColor(.brown)
                        })
                        .padding(.horizontal, 10.0)
                        Button(action: {}, label: {
                            Image(systemName: "frying.pan.fill")
                                .foregroundColor(.gray)
                        })
                    }
                    .padding(.top, 4.0)
                }
                .padding(.horizontal)
            }
            .padding(.bottom)
            .background(Color.white)
            .cornerRadius(8.0)
            .shadow(radius: 4.0)
            // 操作時にバネ運動の様なAnimationを付与する
            // 👉 しきい値を超過せずに元に戻る場合
            .animation(.spring, value: swipeOffset)
            // スワイプ処理時にX軸方法のOffset値を変更する
            .offset(x: swipeOffset.width, y: 4.0)
            // スワイプ処理時にこのView要素に対して回転処理を利用して傾きをつける
            .rotationEffect(.degrees(Double(swipeOffset.width / proxy.size.width) * 24.0), anchor: .bottom)
            // DragGestureを利用してスワイプ動作を組み立てる
            .gesture(
                DragGesture()
                    .onChanged { value in

                        // スワイプ変化量を変数に保持する
                        swipeOffset = value.translation

                        // 移動量の割合に応じてメッセージを表示する
                        if getGesturePercentageBasedOnScreenWidth(proxy: proxy, dragGestureValue: value) >= thresholdMessagePercentage {
                            swipeStatus = .addToCart
                        } else if getGesturePercentageBasedOnScreenWidth(proxy: proxy, dragGestureValue: value) <= -thresholdMessagePercentage {
                            swipeStatus = .notSelect
                        } else {
                            swipeStatus = .none
                        }
                        
                    }
                    .onEnded { value in

                        // 画面幅の半分以上まで動いた際は、この要素を削除対象とする
                        if abs(getGesturePercentageBasedOnScreenWidth(proxy: proxy, dragGestureValue: value)) > thresholdActionPercentage {

                            // 配置元でこのView要素を削除する処理を実行する
                            removeAction(foodMenuEntity)

                        } else {

                            // 現在状態とスワイプ変化量をリセットする
                            swipeOffset = .zero
                            swipeStatus = .none
                        }
                    }
            )
            
        }
    }

    // MARK: - Private Function

    // 画面幅を基準とした移動量の割合を算出する
    private func getGesturePercentageBasedOnScreenWidth(proxy: GeometryProxy, dragGestureValue: DragGesture.Value) -> CGFloat {
        dragGestureValue.translation.width / proxy.size.width
    }
}

以下に、添付コードにおける主なポイントをまとめました。

1.スワイプ状態の管理 (SwipeStatus):

  • enum SwipeStatus { .addToCart, .notSelect, .none }を定義し、カードを右スワイプ (addToCart)・左スワイプ (notSelect)・スワイプ無し (none) の状態に振り分けています。
  • スワイプ量に応じて表示するメッセージ(「🛒今日はコレ!」「🙅違うかなぁ…」)を出し分ける仕組みです。

2.スワイプ動作に応じたビューの見た目と制御:

  • @State var swipeOffset: CGSizeを使い、ユーザーのドラッグ操作でカードを左右に移動させています。
  • rotationEffectを利用し、スワイプ量に応じてカードに角度をつけることで、Tinder風のスワイプ体験を再現しています。

3.アニメーションとDragGestureの利用:

  • .animation(.spring, value: swipeOffset)で「バネ動作」のようなアニメーションを適用
    しています。
  • DragGesture.onChangedで移動量を取得し、.onEndedでスワイプ判定を行います。

4.スワイプのしきい値設定:

  • thresholdActionPercentage (0.45)を超えると、カードを画面外へ削除(removeAction)しています。
  • thresholdMessagePercentage (0.12)を超えるかどうかで、スワイプ途中に表示するメッセージの有無を切り替えをしています。
  • カードが一定以上スワイプされたら削除、それ未満なら元に戻すというシンプルなロジックが実装されています。

5.カード削除のコールバック (removeAction):

  • しきい値を超えた場合は、呼び出し元が管理する配列などからこのカードのデータを削除するため、removeAction(foodMenuEntity)を実行。
  • 親ビュー(ContentViewなど)との連携でスワイプ操作が確定したカードを排除する仕組みを実現しています。

6.レイアウトとUI要素の配置:

  • GeometryReader内でカード幅やスワイプ量を計算し、ZStackとの組み合わせにより上に重ねたテキスト表示などが可能です。
  • カード上部にサムネイル画像やメッセージ、下部にカードに関する情報やボタン群を配置しています。
  • カード全体に角丸や影をつけるなど、見た目を整えています。

これらにより、 画面幅に応じたドラッグ量の計算 → スワイプ方向を判定 → 状態に応じてメッセージや削除アクションを切り替え → アニメーション付きでカードが飛んでいくまたは元に戻る という Tinder風UI の基本的な動きを実装しています。

4. まとめ

Tinder風のスワイプUIは、一見シンプルな仕組みに見えても、実際にはカードの削除やレイアウト調整など考慮すべき要素が多く、思った以上に奥深い実装だと感じました。

特にSwiftUIのGeometryReaderやDragGestureなどを組み合わせることで、画面幅に応じた動的なレイアウトや自然なアニメーションを比較的シンプルなコードで実現できるのは大きな利点です。

アプリにこのUIを組み込むことで、ユーザーに直感的で新鮮な操作感を提供できるのが大きな魅力だと思います。

Discussion