190
152

More than 3 years have passed since last update.

Tinder風なUIを実装する際のアイデアと実装例紹介

Last updated at Posted at 2018-03-03

1. はじめに

iOSの人気アプリTinderで採用されている、「好き」or「嫌い」(Yes or No)を左右のスワイプで答えるようなUIは、シンプル&カジュアルでありながらもその斬新さ故に出た当時から今もなお注目されているUI表現の1つかと思います。また同様な動きを表現するためのUIライブラリについても数多くあることからも、アプリのUXという点においても、その心理的な効果や応用可能性への関心が高いことがうかがえます。

今回はライブラリを用いない実装方法を試すことで、Tinder風のUIを自前で用意する際の実装ポイントや気をつけるべき点等を知りたいと感じたので、同様な形ではあるが内部実装は全く異なるTinder風UIサンプルを2種類を紹介した上での考察をまとめました。

サンプルの全体的な動きの動画:

Githubでのサンプルコード:

※1 こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!
※2 リポジトリへ共有しているコードはすでにSwift4.2 & Xcode10.1へコンバート済みです。

本記事でご紹介している内容に関しましては、株式会社Sansan様にて開催された「第2回 iOS UI実装勉強会」の中でも登壇時にサンプルを交えてご紹介しましたので、その際に使用したスライド等も併せて共有致します。

2. 今回の参考資料とサンプル概要について

今回紹介するサンプルについては、下記の2パターンの実装に関する解説になります。

  • 実装例1. UIView + UIPanGestureRecognizerを利用したUI実装
  • 実装例2. UICollectionView + UILongPressGestureRecognizerを利用したUI実装

この2つについては実装方法やUIの構成については大きく異なりますが、指の動きに合わせてカードの位置を動かす処理・スワイプをして左端ないしは右端に近い位置で指を離すと画面範囲外へ消えていく処理といった基本部分の動きやUIの見た目については、できるだけ似た形になる様にしています。

そして、Tinder風なUIを実装するにあたっての実装ポイントとして大切なのは下記の3つになるかと思います。

  1. 選択したカード状のUIを左右に動かす動き
  2. 配置されているカード状のUIのコントロール
  3. カードが消えたタイミングで実行される処理

さらに加えてViewに画面する際の処理やカード状のUIの細かな動きの微調整についても、より綺麗なUIを実現するためにこだわるポイントになります。

★2-1. このサンプルを実装するにあたっての参考資料:

今回のサンプル実装にあたっては下記の記事やGithubのリポジトリ内での実装を参考にしました。

補足:

左右へのドラッグ動作に伴ってカード状のViewを動かす部分やアニメーション処理の実装に関しては、下記の動画にて紹介されている実装を参考にしてみても良いかもしれません。
※ 動画で紹介しているサンプルはXCode8+Swift3での例になりますが、XCode9+Swift4でも問題なく実装できるかと思います。

★2-2. 今回のサンプルについて:

sample_summary.jpg

サンプルに関する説明1:

こちらのサンプルは「UIView + UIPanGestureRecognizer」の組み合わせで作成したサンプルになります。動かすカードのView側にProtocolをあらかじめ定義しておき、UIViewControllerを連携してGestureRecognizer発動時のタイミングで行われる処理(左右への動きやスワイプ後などの処理)を実行するような形の実装になっています。

example1_design.jpg

下記はこの実装を行う上での各々のクラスの関係図&解説メモになります。

example1_memo.jpg

サンプルに関する説明2:

こちらのサンプルは「UICollectionView + UILongPressGestureRecognize」の組み合わせで作成したサンプルになります。本来のUICollectionViewの使い方からは若干外れた形にはなりますが、UICollectionViewLayoutクラスを継承したクラスを利用してカード表示画面の設定を行い、GestureRecognizer発動時のタイミングで行われる処理と組み合わせる形の実装になっています。

example2_design.jpg

下記はこの実装を行う上での各々のクラスの関係図&解説メモになります。

example2_memo.jpg

環境やバージョンについて:

  • Xcode12.3
  • Swift5.3
  • MacOS Big Sur (Ver11.1)

使用ライブラリ:

基本的にはiOSの開発で良く用いられるライブラリを最低限使用しています。今回のメイン部分であるTinder風のUI構築に関しては今回は特にライブラリを用いずに実装しています。

ライブラリ名 ライブラリの機能概要
SwiftyJSON JSONデータの解析をしやすくする
Alamofire HTTPないしはHTTPSのネットワーク通信用
Kingfisher 画像URLからの非同期での画像表示とキャッシュサポート
PromiseKit 非同期通信のハンドリング

3. このサンプルにおける共通の実装部分に関する解説

今回のサンプル作成にあたり、2種類のサンプルの中で共通で使用する部分の実装に関する解説になります。
レシピデータの取得に関する処理とカード状Viewのデザイン調整に関連する処理に関しては下記のような構成で実装しています。

★3-1. 本サンプルで使用しているAPIについて:

今回のサンプルについては、カード部分のViewに表示しているデータに関しては、 「楽天レシピカテゴリ別ランキングAPI」 から、カテゴリーIDの一覧データからランダムに選択されたカテゴリーに紐づくレシピデータを4件取得する形にしています。

※ カテゴリーIDの一覧については、サンプルコード内のrakuten_recipe_category_list.csvに格納されています。

楽天レシピカテゴリ別ランキングAPIを利用する際は、Rakuten Developersに登録した上でアプリIDを取得し、APIConstant.swiftの下記の部分に取得したアプリIDをセットしてください。

APIConstant.swift
// 楽天レシピ別カテゴリランキングのAPIキー ※各自取得をお願いします。
static let API_KEY_RAKUTEN_RECIPE_RANKING = ""

※ このAPIのレスポンスで返却される画像データはhttp:なので、今回のサンプルではATS(App Transport security)をOFFにしています。

★3-2. API経由でのデータの取得からUIへの反映までの流れ:

画面表示時(viewDidLoad())及び追加ボタン押下時に画面内に4件のレシピデータを表示&追加を行う処理に関しては、「ViewController - Presenter -Model」の構成にし、APIからの非同期処理でのデータ取得からUIへの反映までの処理を整理しやすくしています。

APIRequestManager.swift (Alamofire + PromiseKitを組み合わせたAPIリクエスト処理):

APIRequestManager.swift
import Foundation
import Alamofire
import SwiftyJSON
import PromiseKit

// APIへのアクセスを汎用的に利用するための構造体
// 参考:https://qiita.com/tmf16/items/d2f13088dd089b6bb3e4

struct APIRequestManager {

    // APIのベースとなるURL情報
    private let apiBaseURL = "https://app.rakuten.co.jp/services/api"

    // URLアクセス用のメンバ変数
    let apiUrl: String
    let method: HTTPMethod
    let parameters: Parameters

    init(endPoint: String, method: HTTPMethod = .get, parameters: Parameters = [:]) {

        // イニシャライザの定義
        apiUrl = apiBaseURL + endPoint
        self.method = method
        self.parameters = parameters
    }

    // 該当APIのエンドポイントに向けてデータを取得する
    func request() -> Promise<JSON> {

        return Promise { seal in
            AF.request(apiUrl, method: .get, parameters: parameters, encoding: URLEncoding.default).validate().responseJSON { response in

                switch response.result {

                // 成功時の処理(以降はレスポンス結果を取得して返す)
                case .success(let response):
                    let json = JSON(response)
                    seal.fulfill(json)

                // 失敗時の処理(以降はエラーの結果を返す)
                case .failure(let error):
                    seal.reject(error)
                }
            }
        }
    }
}

RecipeModel.swift (カードへ表示する内容のデータ定義):

RecipeModel.swift
import Foundation
import UIKit
import SwiftyJSON

struct RecipeModel {

    //メンバ変数(取得したJSONレスポンスのKeyに対応する値が入る)
    let recipeId: Int
    let recipeTitle: String
    let recipeUrl: String
    let foodImageUrl: String
    let recipeIndication: String
    let recipeCost: String
    let recipeDescription: String
    let recipePublishday: String

    //イニシャライザ(取得したJSONレスポンスに対して必要なものを抽出する)
    init(result: JSON) {
        self.recipeId          = result["recipeId"].int ?? 0
        self.recipeTitle       = result["recipeTitle"].string ?? ""
        self.recipeUrl         = result["recipeUrl"].string ?? ""
        self.foodImageUrl      = result["foodImageUrl"].string ?? ""
        self.recipeIndication  = result["recipeIndication"].string ?? ""
        self.recipeCost        = result["recipeCost"].string ?? ""
        self.recipeDescription = result["recipeDescription"].string ?? ""
        self.recipePublishday  = result["recipePublishday"].string ?? ""
    }
}

RecipePresenter.swift (ViewControllerへのUI表示処理とデータ取得処理の連結部分):

RecipePresenter.swift
import Foundation
import UIKit
import SwiftyJSON

protocol RecipePresenterProtocol: class {
    func bindRecipes(_ recipes: [RecipeModel])
    func showErrorMessage()
}

class RecipePresenter {

    var presenter: RecipePresenterProtocol!

    // MARK: - Initializer

    init(presenter: RecipePresenterProtocol) {
        self.presenter = presenter
    }

    // MARK: - Functions

    // レシピデータをAPI経由で取得する
    func getRecipes() {

        let parameters = [
            "format"        : "json",
            "applicationId" : APIConstant.API_KEY_RAKUTEN_RECIPE_RANKING,
            "categoryId"    : APIConstant.getCategoryByRandom()
        ]

        // 楽天レシピカテゴリ別ランキングAPIへアクセスをするための準備をする
        let apiRequestManager = APIRequestManager(
            endPoint: "/Recipe/CategoryRanking/20121121",
            method: .get,
            parameters: parameters
        )

        // 楽天レシピカテゴリ別ランキングAPIへの通信処理を実行する
        apiRequestManager.request()
            // 成功時の処理をクロージャー内に記載する
            .done { data in
                // JSONデータを解析してRecipeModel型データを作成
                let json = JSON(data)
                let recipes: [RecipeModel] = json["result"].map{ (_, result) in
                    return RecipeModel(result: JSON(result))
                }
                // 通信成功時の処理をプロトコルを適用したViewController側で行う
                self.presenter.bindRecipes(recipes)
            }
            // 失敗時の処理をクロージャー内に記載する
            .catch { error in
                // 通信失敗時の処理をプロトコルを適用したViewController側で行う
                self.presenter.showErrorMessage()
            }
    }
}

ViewController側には、RecipePresenterProtocolで定義した下記のメソッドの実装内にView側の更新処理を記載して、Presenter側に定義したgetRecipes()の処理と連動する様な形にしておきます。

★3-3.取得したデータを表示するカード状のViewのデザインに関する設定:

今回の構成では、取得したデータを表示するカード状のViewのデザインに関する定数に関しては、種類が多くなりがち&できるだけ1箇所にまとめておきたかったこともあり、下記のような形で調整を行えるようにする値に関するプロトコルを定義した上で、前述のプロトコルを適用したクラス内に具体的な定数値を設定する形にしています。

取得したデータを表示するカード状のViewのデザイン調整用定数を定義するプロトコル:

TinderCardSetting.swift
protocol TinderCardSetting {

    // MARK: - Static Properties

    // カード用View高さ
    static var cardSetViewWidth: CGFloat { get }

    // カード用View幅
    static var cardSetViewHeight: CGFloat { get }

    // ・・・(以下カード用Viewのデザイン設定に関する変数を定義する)・・・
}

取得したデータを表示するカード状のViewのデザイン調整用定数の具体的な値を定義するクラス:

TinderCardDefaultSettings.swift
class TinderCardDefaultSettings: TinderCardSetting {

    // ・・・(省略)・・・

    // MARK: - TinderCardSetViewSettingプロトコルで定義した変数

    static var cardSetViewWidth: CGFloat = 300

    static var cardSetViewHeight: CGFloat = 320

    // ・・・(以下カード用Viewのデザイン設定に関する定数値)・・・
}

4. UIView + UIPanGestureRecognizerを組み合わせた実装サンプルに関する解説

ここからは、UIView + UIPanGestureRecognizerを用いてTinder風のUIを作成する上でポイントとなるコードに関して解説をしていきます。
動きを実現する上での実装ポイントやカードを配置する際やUIPangestureRecognizer発動時のカードが重なっている部分の細かなアニメーションを実現するためのTipsをはじめ、コード内にコメントをできるだけ加えていますので、Github上にあるサンプルコードと併せてご覧いただければ幸いです。

★4-1. ViewController側へ処理を橋渡しするためのプロトコルの定義:

まずはカード状のViewを表示するためのクラス側に、ViewController側の処理とのを橋渡しするためのプロトコルを定義してカード状のViewを動かしたタイミングで配置しているViewController側の処理を実行できる様にします。

TinderCardSetView.swift
protocol TinderCardSetDelegate: NSObjectProtocol {

    // ドラッグ開始時に実行されるアクション
    func beganDragging(_ cardView: TinderCardSetView)

    // 位置の変化が生じた際に実行されるアクション
    func updatePosition(_ cardView: TinderCardSetView, centerX: CGFloat, centerY: CGFloat)

    // 左側へのスワイプ動作が完了した場合に実行されるアクション
    func swipedLeftPosition(_ cardView: TinderCardSetView)

    // 右側へのスワイプ動作が完了した場合に実行されるアクション
    func swipedRightPosition(_ cardView: TinderCardSetView)

    // 元の位置に戻る動作が完了したに実行されるアクション
    func returnToOriginalPosition(_ cardView: TinderCardSetView)
}

UIViewController側の処理に関しては、下記のような形で実装しています。画面上に配置しているカード状のViewのクラス内に定義したUIPanGestureRecognizerのそれぞれのタイミング(state)に合わせて、ViewController側の処理とカード状View側の処理を一緒に行うようにしています。

PureViewTinderViewController.swift
class PureViewTinderViewController: UIViewController, SFSafariViewControllerDelegate {

    // カード表示用のViewを格納するための配列
    fileprivate var tinderCardSetViewList: [TinderCardSetView] = []

    ・・・(省略)・・・

    // 画面上にあるカードの山のうち、一番上にあるViewのみを操作できるようにする
    fileprivate func enableUserInteractionToFirstCardSetView() {
        if !tinderCardSetViewList.isEmpty {
            if let firsttTinderCardSetView = tinderCardSetViewList.first {
                firsttTinderCardSetView.isUserInteractionEnabled = true
            }
        }
    }

    // 現在配列に格納されている(画面上にカードの山として表示されている)Viewの拡大縮小を調節する
    fileprivate func changeScaleToCardSetViews(skipSelectedView: Bool = false) {

        // アニメーション関連の定数値
        let duration: TimeInterval = 0.26
        let reduceRatio: CGFloat   = 0.018

        var targetCount: CGFloat = 0
        for (targetIndex, tinderCardSetView) in tinderCardSetViewList.enumerated() {

            // 現在操作中のViewの縮小比を変更しない場合は、以降の処理をスキップする
            if skipSelectedView && targetIndex == 0 { continue }

            // 後ろに配置されているViewほど小さく見えるように縮小比を調節する
            let targetScale: CGFloat = 1 - reduceRatio * targetCount
            UIView.animate(withDuration: duration, animations: {
                tinderCardSetView.transform = CGAffineTransform(scaleX: targetScale, y: targetScale)
            })
            targetCount += 1
        }
    }
}

・・・(省略)・・・

// MARK: - TinderCardSetDelegate

extension PureViewTinderViewController: TinderCardSetDelegate {

    // ドラッグ処理が開始された際にViewController側で実行する処理
    func beganDragging(_ cardView: TinderCardSetView) {

        // Debug.
        //print("ドラッグ処理が開始されました。")

        changeScaleToCardSetViews(skipSelectedView: true)
    }

    // ドラッグ処理中に位置情報が更新された際にViewController側で実行する処理
    func updatePosition(_ cardView: TinderCardSetView, centerX: CGFloat, centerY: CGFloat) {

        // Debug.
        //print("移動した座標点 X軸:\(centerX) Y軸:\(centerY)")
    }

    // 左方向へのスワイプが完了した際にViewController側で実行する処理
    func swipedLeftPosition(_ cardView: TinderCardSetView) {

        // Debug.
        //print("左方向へのスワイプ完了しました。")

        tinderCardSetViewList.removeFirst()
        enableUserInteractionToFirstCardSetView()
        changeScaleToCardSetViews(skipSelectedView: false)
    }

    // 右方向へのスワイプが完了した際にViewController側で実行する処理
    func swipedRightPosition(_ cardView: TinderCardSetView) {

        // Debug.
        //print("右方向へのスワイプ完了しました。")

        tinderCardSetViewList.removeFirst()
        enableUserInteractionToFirstCardSetView()
        changeScaleToCardSetViews(skipSelectedView: false)
    }

    // 元の位置へ戻った際にViewController側で実行する処理
    func returnToOriginalPosition(_ cardView: TinderCardSetView) {

        // Debug.
        //print("元の位置へ戻りました。")

        changeScaleToCardSetViews(skipSelectedView: false)
    }
}

今回は特に左右スワイプが完了した際の処理については、カードに表示するデータの配列から最初のデータを削除して、次のカードの操作を有効にする処理ぐらいしか実装していませんが、カード状のUIView側の状態に応じての処理を追加する等をすることで、デザイン的にも様々なカスタマイズができるようになるかと思います。

★4-2. UIPanGestureRecognizerの状態に合わせた処理の解説:

カード状のViewを指の動きに合わせて動かす部分の処理に関しては、UIPanGestureRecognizerの状態(stateプロパティ)に合わせた処理をそれぞれ実装していきます。
特にカードが動いている状態や指が離れた際には、動きのより細かい調整ができるように座標位置の更新を行なうだけではなく、「中心からどのぐらい離れているか」を変化の割合を変数にセットしておき、カードを動かしている最中の傾きを算出や指を話した際のアニメーションに活用をしています。

TinderCardSetView.swift
class TinderCardSetView: CustomViewBase {

    ・・・(省略)・・・

    // ドラッグが開始された際に実行される処理
    @objc private func startDragging(_ sender: UIPanGestureRecognizer) {

        // 中心位置からのX軸&Y軸方向の位置の値を更新する
        xPositionFromCenter = sender.translation(in: self).x
        yPositionFromCenter = sender.translation(in: self).y

        // UIPangestureRecognizerの状態に応じた処理を行う
        switch sender.state {

        // ドラッグ開始時の処理
        case .began:

            // ドラッグ処理開始時のViewがある位置を取得する
            originalPoint = CGPoint(
                x: self.center.x - xPositionFromCenter,
                y: self.center.y - yPositionFromCenter
            )

            // DelegeteメソッドのbeganDraggingを実行する
            self.delegate?.beganDragging(self)

            // Debug.
            //print("beganCenterX:", originalPoint.x)
            //print("beganCenterY:", originalPoint.y)

            // ドラッグ処理開始時のViewのアルファ値を変更する
            UIView.animate(withDuration: durationOfStartDragging, delay: 0.0, options: [.curveEaseInOut], animations: {
                self.alpha = self.startDraggingAlpha
            }, completion: nil)

            break

        // ドラッグ最中の処理
        case .changed:

            // 動かした位置の中心位置を取得する
            let newCenterX = originalPoint.x + xPositionFromCenter
            let newCenterY = originalPoint.y + yPositionFromCenter

            // Viewの中心位置を更新して動きをつける
            self.center = CGPoint(x: newCenterX, y: newCenterY)

            // DelegeteメソッドのupdatePositionを実行する
            self.delegate?.updatePosition(self, centerX: newCenterX, centerY: newCenterY)

            // 中心位置からのX軸方向へ何パーセント移動したか(移動割合)を計算する
            currentMoveXPercentFromCenter = min(xPositionFromCenter / UIScreen.main.bounds.size.width, 1)

            // 中心位置からのY軸方向へ何パーセント移動したか(移動割合)を計算する
            currentMoveYPercentFromCenter = min(yPositionFromCenter / UIScreen.main.bounds.size.height, 1)

            // Debug.
            //print("currentMoveXPercentFromCenter:", currentMoveXPercentFromCenter)
            //print("currentMoveYPercentFromCenter:", currentMoveYPercentFromCenter)

            // 上記で算出したX軸方向の移動割合から回転量を取得し、初期配置時の回転量へ加算した値でアファイン変換を適用する
            let initialRotationAngle = atan2(initialTransform.b, initialTransform.a)
            let whenDraggingRotationAngel = initialRotationAngle + CGFloat.pi / 10 * currentMoveXPercentFromCenter
            let transforms = CGAffineTransform(rotationAngle: whenDraggingRotationAngel)

            // 拡大縮小比を適用する
            let scaleTransform: CGAffineTransform = transforms.scaledBy(x: maxScaleOfDragging, y: maxScaleOfDragging)
            self.transform = scaleTransform

            break

        // ドラッグ終了時の処理
        case .ended, .cancelled:

            // ドラッグ終了時点での速度を算出する
            let whenEndedVelocity = sender.velocity(in: self)

            // Debug.
            //print("whenEndedVelocity:", whenEndedVelocity)

             // 移動割合のしきい値を超えていた場合には、画面外へ流れていくようにする(しきい値の範囲内の場合は元に戻る)
            let shouldMoveToLeft  = (currentMoveXPercentFromCenter < -swipeXPosLimitRatio && abs(currentMoveYPercentFromCenter) > swipeYPosLimitRatio)
            let shouldMoveToRight = (currentMoveXPercentFromCenter > swipeXPosLimitRatio && abs(currentMoveYPercentFromCenter) > swipeYPosLimitRatio)

            if shouldMoveToLeft {
                moveInvisiblePosition(verocity: whenEndedVelocity, isLeft: true)
            } else if shouldMoveToRight {
                moveInvisiblePosition(verocity: whenEndedVelocity, isLeft: false)
            } else {
                moveOriginalPosition()
            }

            // ドラッグ開始時の座標位置の変数をリセットする
            originalPoint = CGPoint.zero
            xPositionFromCenter = 0.0
            yPositionFromCenter = 0.0
            currentMoveXPercentFromCenter = 0.0
            currentMoveYPercentFromCenter = 0.0

            break

        default:
            break
        }
    }

    ・・・(省略)・・・

    // カードを初期配置する位置へ戻す
    private func moveInitialPosition() {

        // 表示前のカードの位置を設定する
        let beforeInitializePosX: CGFloat = CGFloat(Int.createRandom(range: Range(-300...300)))
        let beforeInitializePosY: CGFloat = CGFloat(-Int.createRandom(range: Range(300...600)))
        let beforeInitializeCenter = CGPoint(x: beforeInitializePosX, y: beforeInitializePosY)

        // 表示前のカードの傾きを設定する
        let beforeInitializeRotateAngle: CGFloat = CGFloat(Int.createRandom(range: Range(-90...90)))
        let angle = beforeInitializeRotateAngle * .pi / 180.0
        let beforeInitializeTransform = CGAffineTransform(rotationAngle: angle)
        beforeInitializeTransform.scaledBy(x: beforeInitializeScale, y: beforeInitializeScale)

        // 画面外からアニメーションを伴って現れる動きを設定する
        self.alpha = 0
        self.center = beforeInitializeCenter
        self.transform = beforeInitializeTransform

        UIView.animate(withDuration: durationOfInitialize, animations: {
            self.alpha = 1
            self.center = self.initialCenter
            self.transform = self.initialTransform
        })
    }

    // カードを元の位置へ戻す
    private func moveOriginalPosition() {

        UIView.animate(withDuration: durationOfReturnOriginal, delay: 0.0, usingSpringWithDamping: 0.68, initialSpringVelocity: 0.0, options: [.curveEaseInOut], animations: {

            // ドラッグ処理終了時はViewのアルファ値を元に戻す
            self.alpha = self.stopDraggingAlpha

            // Viewの配置を元の位置まで戻す
            self.center = self.initialCenter
            self.transform = self.initialTransform

        }, completion: nil)

        // DelegeteメソッドのreturnToOriginalPositionを実行する
        self.delegate?.returnToOriginalPosition(self)

    }

    // カードを左側の領域外へ動かす
    private func moveInvisiblePosition(verocity: CGPoint, isLeft: Bool = true) {

        // 変化後の予定位置を算出する(Y軸方向の位置はverocityに基づいた値を採用する)
        let absPosX = UIScreen.main.bounds.size.width * 1.6
        let endCenterPosX = isLeft ? -absPosX : absPosX
        let endCenterPosY = verocity.y
        let endCenterPosition = CGPoint(x: endCenterPosX, y: endCenterPosY)

        UIView.animate(withDuration: durationOfSwipeOut, delay: 0.0, usingSpringWithDamping: 0.68, initialSpringVelocity: 0.0, options: [.curveEaseInOut], animations: {

            // ドラッグ処理終了時はViewのアルファ値を元に戻す
            self.alpha = self.stopDraggingAlpha

            // 変化後の予定位置までViewを移動する
            self.center = endCenterPosition

        }, completion: { _ in

            // DelegeteメソッドのswipedLeftPositionを実行する
            let _ = isLeft ? self.delegate?.swipedLeftPosition(self) : self.delegate?.swipedRightPosition(self)

            // 画面から該当のViewを削除する
            self.removeFromSuperview()
        })
    }
}

このように、大まかな動きの部分に関するポイントは割とシンプルに実装できますが、より自然かつ細やかな動きを演出する際には、カードの大きさやアニメーションに関するパラメータの調整についてもより細かく注目した上で、ハンドリングをする必要が出てくるかもしれません。
細部にこだわることによって必要な処理が複雑なものになってしまうデメリットはあるかもしれませんが、UI/UXをさらに向上させるための重要なポイントになるかと思います。

5. UICollectionView + UILongPressGestureRecognizerを組み合わせた実装サンプルに関する解説

ここからは、UICollectionView + UILongPressGestureRecognizer用いてTinder風のUIを作成する上でポイントとなるコードに関して解説をしていきます。
初期状態やカードを新たに追加した際に実行されるカード配置に関する処理については、UICollectionViewを用いて行い、一番上のカードを長押しした際にカードを動かせる様にする等、カードの動きや付随する処理についてはUILongPressGestureRecognizerを利用しています。

こちらの実装につきましても、コード内にコメントをできるだけ加えていますので、Github上にあるサンプルコードと併せてご覧いただければ幸いです。またこの実装方法については、本来のUICollectionViewの使用用途とは若干かけ離れてしまう形にはなるかもわかりませんが、このような表現の仕方もあるということで認識して頂ければと思います。

★5-1. UICollectionViewLayoutを利用したセルの配置:

UICollectionViewでは、表示するセルのサイズや余白を管理するクラスであるUICollectionViewLayoutクラスを持ち、このクラスに手を入れることによってUICollectionViewに配置しているセルのレイアウトを調整することができます。

参考資料:

また、UICollectionViewに配置されるセルについても、各IndexPathのセルのレイアウト属性(UICollectionViewLayoutAttributes)を持っておりその中でセルのサイズやレイアウトに関する情報を管理しています。今回の様な配置をUICollectionViewで実現する場合には「UICollectionViewLayoutクラスを継承したクラスを作成し、さらにUICollectionViewのレイアウトを組み立てる際の処理をオーバーライドし、UICollectionViewLayoutAttributesの値を調整する」という方針になります。

参考資料:

オーバーライドが必要なメソッドとその部分で行う処理についてまとめると、

1. override func prepare()

この部分はUICollectionViewが描画される前に1度だけ実行されるメソッドになり、このタイミングでセルごとのUICollectionViewLayoutAttributesを算出するための実装をしています。この部分では、

  • UICollectionViewのカードの並び方を設定する(奥に行くほど少し小さくなるようにして奥行きを出す)
  • カードが消えてレイアウトが更新されたタイミングで各セルのUICollectionViewLayoutAttributesが反映されるようにする

の2つの役割を担っています。

2. override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?

この部分はUICollectionViewに表示されるレイアウトに関する属性値の配列を返すメソッドになり、この部分では、

  • UICollectionViewが描画されるまえに設定したセルごとのUICollectionViewLayoutAttributesを反映させる

となり、上記のことを踏まえて全体の実装をまとめると下記のようになります。

TinderCardCollectionViewLayout.swift
import UIKit

class TinderCardCollectionViewLayout: UICollectionViewLayout {

    // MEMO: 下記の記事を参考に作成しています。
    // 「UICollectionViewのLayoutで悩んだら」
    // http://techlife.cookpad.com/entry/2017/06/29/190000

    // 設定したレイアウト属性を格納するための変数
    private var layout = [UICollectionViewLayoutAttributes]()

    // セル表示時の拡大縮小の変化割合とアルファ値の割合
    private let reduceRatio: CGFloat = 0.018
    private let alphaRatio: CGFloat  = 0.008

    // レイアウトの事前計算を行う前に実行する
    override func prepare() {
        super.prepare()

        // 設定したレイアウト属性を事前計算処理前にリセットする
        layout.removeAll()

        // UICollectionViewの要素数を取得する
        var numberOfItemCount: Int = 0
        if let targetCollectionView = collectionView {
            numberOfItemCount = targetCollectionView.numberOfItems(inSection: 0)
        }

        // 現在画面に表示されている要素分のレイアウト属性の算出を行う
        for targetCount in (0..<numberOfItemCount).reversed() {

            // indexPathの値を取得する
            let indexPath = IndexPath(item: targetCount, section: 0)

            // X軸&Y軸の値を新たに算出する
            let newPositionX: CGFloat = (UIScreen.main.bounds.width - TinderCardDefaultSettings.cardSetViewWidth) / 2
            let newPositionY: CGFloat = (UIScreen.main.bounds.height - TinderCardDefaultSettings.cardSetViewHeight) / 2.7
                - CGFloat(6 * targetCount)

            // レイアウトの配列に位置とサイズに関する情報を登録する
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.frame = CGRect(
                x: newPositionX,
                y: newPositionY,
                width: TinderCardDefaultSettings.cardSetViewWidth,
                height: TinderCardDefaultSettings.cardSetViewHeight
            )

            // 後ろに配置されているUICollectionViewCellほど小さく見えるように拡大縮小比を調節する
            let targetScale: CGFloat = 1 - reduceRatio * CGFloat(targetCount)
            let targetAlpha: CGFloat = 1 - alphaRatio * CGFloat(targetCount)

            attributes.alpha = targetAlpha
            attributes.transform = CGAffineTransform(scaleX: targetScale, y: targetScale)
            attributes.zIndex = numberOfItemCount - targetCount

            layout.append(attributes)
        }
    }

    // 範囲内に含まれるすべてのセルのレイアウト属性を返す
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        super.layoutAttributesForElements(in: rect)

        return layout
    }
}

あとは、InterfaceBuilderに作成したクラスを紐づければ、それぞれのセルが少しずつずれながら重なっている表現で配置されるようになります。

InterfaceBuilderと紐づける:

attatch_with_intefacebuilder.png

★5-2. UICollectionViewCellから対象のセルを選出する処理について:

次に、対象のセルを選出する処理について考えていきます。UICollectionViewCellを配置する際にcell.tag = indexPath.rowとなるようにし、UILongPressGestureRecognizerをUICollectionViewCellに対して追加します。

そして、UILongPressGestureが発動した際に下記のような形でセルのtagプロパティに定義した値から、該当のセルに関する情報を抽出していきます。

CollectionViewTinderViewController.swift
・・・(省略)・・・

// MARK: - UICollectionViewDelegate, UICollectionViewDataSource

extension CollectionViewTinderViewController: UICollectionViewDelegate, UICollectionViewDataSource {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return recipeDataList.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: tinderCardCollectionViewCellIdentifier, for: indexPath) as! TinderCardCollectionViewCell
        let recipe = recipeDataList[indexPath.row]

        cell.isUserInteractionEnabled = (indexPath.row > 0) ? false : true
        cell.tag = indexPath.row
        cell.setCellData(recipe)
        cell.readmoreButtonAction = {

            // 遷移先のURLをセットする
            if let linkUrl = URL(string: recipe.recipeUrl) {
                let safariViewController = SFSafariViewController(url: linkUrl)
                safariViewController.delegate = self
                self.present(safariViewController, animated: true, completion: nil)
            }
        }

        // UILongPressGestureRecognizerの定義を行う
        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(self.longPressCell(sender:)))

        // イベント発生までのタップ時間:0.05秒
        longPressGesture.minimumPressDuration = 0.05

        // 指のズレを許容する範囲:0.5px
        longPressGesture.allowableMovement = 0.5

        // セルに対してLongPressGestureRecognizerを付与する
        cell.addGestureRecognizer(longPressGesture)

        return cell
    }

    // MARK: - Private Function

    // セルを長押しした際(UILongPressGestureRecognizerで実行された際)に発動する処理
    @objc private func longPressCell(sender: UILongPressGestureRecognizer) {

        guard let targetView = sender.view else { return }

        // 長押ししたセルのタグ名と現在位置を設定する
        let targetTag: Int = targetView.tag
        let pressPoint: CGPoint = sender.location(ofTouch: 0, in: self.view)

        // 現在の中心位置を算出する
        let centerX = pressPoint.x
        let centerY = pressPoint.y

        ・・・(省略)・・・

        // 長押し対象のセルに配置されていたものを格納するための変数
        var targetCell: TinderCardCollectionViewCell? = nil

        // CollectionView内の要素で該当のセルのものを抽出する
        for targetView in tinderCardSetCollectionView.subviews {
            if targetView is TinderCardCollectionViewCell {
                let cc = targetView as! TinderCardCollectionViewCell
                if cc.tag == targetTag {
                    targetCell = cc
                    break
                }
            }
        }

        ・・・(省略)・・・
}

UILongPressGestureRecognizerは、イベント発生までの時間(minimumPressDuration)や指のズレを許容する範囲(allowableMovement)をプロパティとして持つので、発動位置や時間のコントロールができ、今回は「カード束の一番上から選択するViewを抜き出すような挙動」を実現するために活用してみました。

★5-3. UILongPressGestureRecognizerの状態に合わせた処理の解説:

UILongPressGestureRecognizer経由で対象のセルを選び出した後は、stateプロパティの状態を元にそれぞれの処理を記載していく形になります。
このサンプルで実際に動いているのは、選び出したUICollectionViewCellではなく、「UICollectionViewCellのスナップショットを表示したUIImageView」になります。

stateプロパティの状態で実行される処理の概要をまとめると、

1. UIGestureRecognizerState.began(開始時):

  • 指の動きに合わせて動くUIImageViewのメンバ変数「draggableImageView」の画面への追加と初期位置の決定
  • tagプロパティに該当するのUICollectionViewCellをUICollectionView上から見えなくする
  • tagプロパティに該当するのUICollectionViewCellのスナップショットを取得

2. UIGestureRecognizerState.changed(指を動かしている時):

  • 指の動きに合わせて動くUIImageViewのメンバ変数「draggableImageView」の位置変更
  • 画面外へカードがスワイプアウトするか、元の位置に戻るかの判定

3. UIGestureRecognizerState.ended(指を離した時):

  • 画面外へカードがスワイプアウトする場合は、スワイプする方向を判定した上で画面外に消えて行くアニメーションを実行して、完了後に表示データを格納している配列から先頭のデータを削除してUICollectionViewを再描画する
  • 元の位置に戻る場合は、tagプロパティに該当するのUICollectionViewCellをUICollectionView上に再度表示させる
  • 指の動きに合わせて動くUIImageViewのメンバ変数「draggableImageView」を画面から削除する

となり、上記のことを踏まえて全体の実装をまとめると下記のようになります。

CollectionViewTinderViewController.swift
class CollectionViewTinderViewController: UIViewController, SFSafariViewControllerDelegate {

    ・・・(省略)・・・

    // ドラッグ可能なイメージビュー
    fileprivate var draggableImageView: UIImageView!

    // カード表示用のUICollectionViewCell格納用のレシピデータ配列
    fileprivate var recipeDataList: [RecipeModel] = [] {
        didSet {
            self.tinderCardSetCollectionView.reloadData()
        }
    }

    ・・・(省略)・・・

    // 選択状態の判定用のフラグ
    fileprivate var isSelectedFlag: Bool = false

    ・・・(省略)・・・
}

・・・(省略)・・・

// MARK: - UICollectionViewDelegate, UICollectionViewDataSource

extension CollectionViewTinderViewController: UICollectionViewDelegate, UICollectionViewDataSource {

    ・・・(省略)・・・

    // MARK: - Private Function

    // セルを長押しした際(UILongPressGestureRecognizerで実行された際)に発動する処理
    @objc private func longPressCell(sender: UILongPressGestureRecognizer) {

        guard let targetView = sender.view else { return }

        // 長押ししたセルのタグ名と現在位置を設定する
        let targetTag: Int = targetView.tag
        let pressPoint: CGPoint = sender.location(ofTouch: 0, in: self.view)

        // 現在の中心位置を算出する
        let centerX = pressPoint.x
        let centerY = pressPoint.y

        // ドラッグ可能なImageViewとぶつかる範囲の設定
        let minX: CGFloat = 75.0
        let maxX: CGFloat = UIScreen.main.bounds.width - minX
        let minY: CGFloat = 100.0
        let maxY: CGFloat = UIScreen.main.bounds.height - minY

        // 長押し対象のセルに配置されていたものを格納するための変数
        var targetCell: TinderCardCollectionViewCell? = nil

        // CollectionView内の要素で該当のセルのものを抽出する
        for targetView in tinderCardSetCollectionView.subviews {
            if targetView is TinderCardCollectionViewCell {
                let cc = targetView as! TinderCardCollectionViewCell
                if cc.tag == targetTag {
                    targetCell = cc
                    break
                }
            }
        }

        // UILongPressGestureRecognizerが開始された際の処理
        if sender.state == UIGestureRecognizerState.began {

            guard let targetView = targetCell?.subviews.first else { return }

            // セル内のViewを非表示にする
            targetCell?.isHidden = true

            // ドラッグ可能なUIImageViewを作成&配置する
            setDraggableImageView(targetView: targetView, x: centerX, y: centerY)
            view.addSubview(draggableImageView)

        // UILongPressGestureRecognizerが動作中の際の処理
        } else if sender.state == UIGestureRecognizerState.changed {

            // 中心位置の更新と回転量の反映を行う
            let diffOfCenterX = pressPoint.x - (UIScreen.main.bounds.size.width / 2)
            let targetRotationAngel = CGFloat.pi / 180 + diffOfCenterX / 1000
            let transforms = CGAffineTransform(rotationAngle: targetRotationAngel)

            draggableImageView.center = CGPoint(x: centerX, y: centerY)
            draggableImageView.transform = transforms

            // Debug.
            //print("x:\(minX) ~ \(maxX), y:\(minY) ~ \(maxY)");
            //print("x:\(pressPoint.x), y:\(pressPoint.y)");

            // 設定した領域の範囲内にあるか否かを判定する
            let containsOfTargetRect: Bool = ((minX <= pressPoint.x && pressPoint.x <= maxX) && (minY <= pressPoint.y && pressPoint.y <= maxY))
            isSelectedFlag = (containsOfTargetRect) ? false : true

        // UILongPressGestureRecognizerが終了した際の処理
        } else if sender.state == UIGestureRecognizerState.ended {

            // 設定した領域の範囲内に中心位置がない場合は該当のレシピデータを削除してUICollectionViewを更新
            if isSelectedFlag {

                // 左右のどちらにスワイプするかを決定する
                let isSwipeLeft  = (minX > pressPoint.x)
                let isSwipeRight = (pressPoint.x > maxX)

                var swipeOutPosX: CGFloat = 0
                let swipeOutPosY: CGFloat = self.draggableImageView.center.y

                UIView.animate(withDuration: TinderCardDefaultSettings.durationOfSwipeOut / 2.5, animations: {

                    if isSwipeLeft {
                        swipeOutPosX = -UIScreen.main.bounds.width * 2.0
                    } else if isSwipeRight {
                        swipeOutPosX = UIScreen.main.bounds.width * 2.0
                    }

                    self.draggableImageView.center = CGPoint(x: swipeOutPosX, y: swipeOutPosY)

                }, completion: { _ in

                    if isSwipeLeft {
                        self.swipeOutLeftDraggableImageView()
                    } else if isSwipeRight {
                        self.swipeOutRightDraggableImageView()
                    }

                    self.recipeDataList.remove(at: targetTag)
                    self.removeDraggableImageView()

                    // セル内のViewを表示する
                    targetCell?.isHidden = false
                })
                isSelectedFlag = false

            // 設定した領域の範囲内に中心位置がある場合はUICollectionViewの表示を元に戻す
            } else {

                removeDraggableImageView()

                // セル内のViewを表示する
                targetCell?.isHidden = false
            }
        }
    }

    // ドラッグ可能なUIImageViewを左側の画面外へ動かす
    private func swipeOutLeftDraggableImageView() {

        // Debug.
        //print("左方向へのスワイプ完了しました。")
    }

    // ドラッグ可能なUIImageViewを右側の画面外へ動かす
    private func swipeOutRightDraggableImageView() {

        // Debug.
        //print("右方向へのスワイプ完了しました。")
    }

    // ドラッグ可能なUIImageViewを画面から消去する
    private func removeDraggableImageView() {
        draggableImageView.image = nil
        draggableImageView.removeFromSuperview()
    }

    // ドラッグ可能なUIImageViewに関する初期設定をする
    private func setDraggableImageView(targetView: UIView, x: CGFloat, y: CGFloat) {

        draggableImageView = UIImageView()
        draggableImageView.frame.size = CGSize(
            width: TinderCardDefaultSettings.cardSetViewWidth,
            height: TinderCardDefaultSettings.cardSetViewHeight
        )
        draggableImageView.center = CGPoint(
            x: x,
            y: y
        )
        draggableImageView.backgroundColor = UIColor.white
        draggableImageView.image = getSnapshotOfCell(inputView: targetView)

        // MEMO: この部分では背景のViewに関する設定のみ実装
        draggableImageView.layer.borderColor   = TinderCardDefaultSettings.backgroundBorderColor
        draggableImageView.layer.borderWidth   = TinderCardDefaultSettings.backgroundBorderWidth
        draggableImageView.layer.cornerRadius  = TinderCardDefaultSettings.backgroundCornerRadius
        draggableImageView.layer.shadowRadius  = TinderCardDefaultSettings.backgroundShadowRadius
        draggableImageView.layer.shadowOpacity = TinderCardDefaultSettings.backgroundShadowOpacity
        draggableImageView.layer.shadowOffset  = TinderCardDefaultSettings.backgroundShadowOffset
        draggableImageView.layer.shadowColor   = TinderCardDefaultSettings.backgroundBorderColor
    }

    // 選択したCollectionViewCellのスナップショットを取得する
    private func getSnapshotOfCell(inputView: UIView) -> UIImage? {

        UIGraphicsBeginImageContextWithOptions(inputView.bounds.size, false, 0.0)
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        inputView.layer.render(in: context)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
    }
}

UICollectionViewを利用してTinder風のUIを実装する場合には、今回の様な形でUICollectionViewからGestureRecognizerが絡む処理の部分を切り離し、なるべくViewController内だけでUIの処理を完結できる様な形での実装を行ないましたが、後述するライブラリtispr-card-stackのようにUICollectionViewのレイアウトの性質や特性に注目した形での実装方法でも可能です。

バージョンを重ねるごとに改善や新機能が追加され、UI構築の際にもよく利用されるUICollectionViewの性質や特性をうまく活用することで、様々なUI構築の表現が実現できるかと思います。

6. Tinder風なUIを用いたアプリの事例やライブラリのピックアップ

Tider風のUIは、ユーザーが「気に入ったアイテムを選択する」という動作に対して、楽しめる要素を盛り込みシンプルかつカジュアルに選択できる効果を持ったUI表現の1つのように思います。
ここでは、補足としてどの様なアプリの中でこのUIが採用されているかという簡単な事例とこのようなUI表現を実現するライブラリに関して下記で簡単に解説していきます。

★6-1. Tinder風なUIを採用しているアプリの事例紹介:

Tinder風のUIを活用している事例を探す中で私がお目にかかったのは、

  • ファッションなどショッピング系のアプリ
  • 転職サービスやマッチング系のアプリ

では、現在でもTinder風な動きをアプリUIに採用されているものがありました。

ここではファッション系のアプリで採用されていた事例を紹介したいと思います。

app_example.jpg

このように、ユーザーに対してレコメンドされた、もしくはユーザーが関心のあるアイテムの中から「自分に合ったものや気に入ったもの」を選択させる操作をシンプルにすることや、より多くのアイテムをユーザーに見てもらうといった効果を期待するようなアプリとはUI/UX的にも相性が良さそうに思えます。

★6-2. Tinder風なUIを実現するライブラリ:

今回のメインはTinder風のUIを自作する上での実装方法やポイントの解説になりますが、Tinder風なUIを実現する際に活用できそうなSwift製のライブラリの一例も下記にピックアップしておきます。ライブラリに関しても動きの気持ち良さやUIデザインにも考慮しているものもあるので、場合によってはTinder風のUI表現をしたい画面にそのまま適用したり、必要最小限のカスタマイズだけで活用したりしてみても良いかと思います。

UICollectionViewをベースに実装しているライブラリ

UIView + UIGestureRecognizerをベースに実装しているライブラリ

7. あとがき

今回のサンプルに関しては、ライブラリを使用しない形で2パターンの同様なUI実装についての解説になりますが、構成が異なっているので細かな部分についての振る舞いも違う物になってくるので、どちらがより良いという結論は難しいところではありますが、UI構築の要件に応じて使い分けをする様な形でも良いかと思います。

実際のアプリへ組み込む際には、例外発生時のケアは勿論ですが、初期配置やGestureRecognizerの発動時や動作中のUIの処理等の細かな部分への配慮については、実装においては大切なポイントになります。また、UIの要件によっては自前で実装するよりもライブラリを活用した方が良い場合もあるかもしれません。

有名なアプリやデザイン・アニメーションが美しいアプリを研究する過程の中でUI実装については、「いったいどのように実装しているんだろう?」と気になることは良くあることかと思います。そしてこの記事が皆様のUI実装時に少しでもご参考になれば幸いです。

追記

2021.01.31: 記事に掲載しているコード及びGithubリポジトリのバージョンを最新のものに更新しました。

190
152
5

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
190
152