iOS
Swift

iOSのTinderUIライブラリを調べてみた

More than 1 year has passed since last update.

はじめに

こんにちは、nirazoです。iOSエンジニアをしてます。
突然ですが皆さん、TinderUIはお好きでしょうか?
TinderUIとは、マッチングアプリのTinderで採用されている、好き、嫌いをカードを左右にスワイプすることによって表現するUIです。

Pecomy.gif
↑こんなやつ

TinderUIが正式名称なのか正直定かでは無いですが、各種記事等でよく見るのでこの名称で呼ぶことにします。

Tinderは海外製のアプリですが、日本のアプリでも、転職関連のサービス等でたまに採用されていたりします。

気づけばTinderのリリースから4年くらい経ち、新しさもすっかり薄れてしましましたが、個人的には良いUIだなーと思っているので、皆もっと使っていこうぜ!という思いを込めて筆を取りました。

TinderUIの良さ

とは言え何が良いんだって話しがあるかと思います。

TinderUIには、
1. 認知的負荷の最小化
2. ゲーミフィケーション
3. 親指だけで操作可能
といった特徴があり1、モバイルならではの手軽さとゲーム性を併せ持っています。
Tinderはマッチングアプリなので対象は人ですが、それ以外にも料理、お店、旅行の宿泊先等、パッと見の雰囲気でフィルタリングしたいときなどは非常に有効なのではないでしょうか。

TinderUIライブラリ

さて、そんなTinderUIを使ったアプリをいざ作るとなると、あのカードを作るのも少し骨が折れそうです。
そんなこんなでTinderUIのライブラリを比較してみました。
下記のiOS界隈ではお馴染みのライブラリ一覧サイトから検索し、スター数、最終更新日などを見て厳選してみました。

結果、見つかったライブラリたちは下記の通りです

ライブラリ Star数 最終更新日 言語
ZLSwipeableViewSwift 1414 2016/12/13 Swift
Koloda 2739 2016/12/02 Swift
MDCSwipeToChoose 2276 2016/08/02 Obj-C

(情報は2016/12/16 1:00現在のもの)

そもそも絶対数が少ない!!

あと数個はあったんですが、スター数少なすぎ問題や更新されてなさすぎ問題があり、今使う候補としてはこの3つかなと言った感じです。しかーしMDCSwipeToChooseはObj-Cで、かつ5ヶ月近く更新が無いため今回はSwift製の2つについて見ていきたいと思います。

ZLSwipeableViewSwift

Swift製のライブラリで、現在も盛んに開発されているようです。Swift3にも対応済み!

簡単な動きの説明

初期化
var swipeableView = ZLSwipeableView(frame: CGRect(x: 0, y: 0, width: 300, height: 500))
view.addSubview(swipeableView)

ZLSwipeableViewを初期化し、View上に配置します。このビューがTinderのカードのベースとなります。

ビューの追加
public var numberOfActiveView = UInt(4)
public var nextView: (() -> UIView?)?

numberOfActivateViewが、画面に表示されるカードの数です。
NextViewにはビューを返すクロージャを定義します。
基本的に、1枚カードをスワイプする毎にnextViewのクロージャが呼ばれ、自動的に後ろにビューが追加されていく動きになります。
また、下記の通り各種コールバックメソッドも用意されています。

swipeableView.didStart = {view, location in
    print("Did start swiping view at location: \(location)")
}
swipeableView.swiping = {view, location, translation in
    print("Swiping at view location: \(location) translation: \(translation)")
}
swipeableView.didEnd = {view, location 
    print("Did end swiping view at location: \(location)")
}

swipeableView.didSwipe = {view, direction, vector in
    print("Did swipe view in direction: \(direction), vector: \(vector)")
}
swipeableView.didCancel = {view in
    print("Did cancel swiping view")
}
swipeableView.didTap = {view, location in
    print("Did tap at location \(location)")
}
swipeableView.didDisappear = { view in
    print("Did disappear swiping view")
}

簡単に実装するとこんな感じです。レイアウトはSnapKitを使っています。

CardView.swift
import UIKit

class CardView: UIView {

    let imageView = UIImageView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    func setup() {
        // Shadow
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.25
        layer.shadowOffset = CGSize(width: 0, height: 1.5)
        layer.shadowRadius = 4.0
        layer.shouldRasterize = true
        layer.rasterizationScale = UIScreen.main.scale

        // Corner Radius
        layer.cornerRadius = 10.0;
        self.clipsToBounds = true

        let contentView = UIView(frame: self.bounds)
        self.addSubview(contentView)

        contentView.addSubview(imageView)
        imageView.snp.makeConstraints { make in
            make.size.equalTo(contentView)
            make.center.equalTo(contentView)
        }
    }

    func setImage(image: UIImage) {
        self.imageView.image = image
    }

}   
ViewController.swift
import UIKit
import SnapKit
import ZLSwipeableViewSwift

class ViewController: UIViewController {

    let swipeableView = ZLSwipeableView()
    let images = [UIImage(named: "Droid"), UIImage(named: "Chrome"), UIImage(named: "Azarashi"), UIImage(named: "Kitakamakura")]
    var imageIndex = 0

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        swipeableView.nextView = {
            return self.nextCardView()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .white
        self.view.addSubview(self.swipeableView)
        self.swipeableView.snp.makeConstraints { make in
            make.left.equalTo(self.view).offset(50)
            make.right.equalTo(self.view).offset(-50)
            make.top.equalTo(self.view).offset(120)
            make.bottom.equalTo(self.view).offset(-100)
        }
        swipeableView.didStart = {view, location in
            print("Did start swiping view at location: \(location)")
        }
        swipeableView.swiping = {view, location, translation in
            print("Swiping at view location: \(location) translation: \(translation)")
        }
        swipeableView.didEnd = {view, location in
            print("Did end swiping view at location: \(location)")
        }
        swipeableView.didSwipe = {view, direction, vector in
            print("Did swipe view in direction: \(direction), vector: \(vector)")
        }
        swipeableView.didCancel = {view in
            print("Did cancel swiping view")
        }
        swipeableView.didTap = {view, location in
            print("Did tap at location \(location)")
        }
        swipeableView.didDisappear = { view in
            print("Did disappear swiping view")
        }
    }

    func nextCardView() -> CardView? {
        if imageIndex >= images.count {
            imageIndex = 0
        }

        let cardView = CardView(frame: swipeableView.bounds)
        guard let image = images[imageIndex] else { return cardView }
        cardView.setImage(image: image)
        imageIndex += 1

        return cardView
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

動作確認

ZLSwipe.gif

うん、問題無いですね。

Undo

このライブラリの良いところは、Undoの処理が行えることです。
スワイプする度に ZLSwipeableView のpropertyである history(UIViewの配列)にビューが追加されていきます。
Undoしたい場合、下記の処理を書くだけでUndo処理が可能!これは簡単で良いですね。

swipeableView.rewind()

カスタマイズ

ZLSwipeableViewSwiftでは、スタックされたビューのアニメーションやスワイプできる方向の指定など、様々なカスタマイズができます。

public var animateView = ZLSwipeableView.defaultAnimateViewHandler()
public var interpretDirection = ZLSwipeableView.defaultInterpretDirectionHandler()
public var shouldSwipeView = ZLSwipeableView.defaultShouldSwipeViewHandler()
public var minTranslationInPercent = CGFloat(0.25)
public var minVelocityInPointPerSecond = CGFloat(750)
public var allowedDirection = Direction.Horizontal

上下左右にスワイプできるようにしたり、スタックされたビューをキレイに見せたりしたいときに便利ですね!

個人的に思ったこと

回転の計算をしっかりしているので、カードの端っこをつまんでスワイプするとカードが激しく回転しながら飛んでいきます。逆に真ん中を掴むとほとんど回転せず飛んでいく。このアニメーションが、アプリによっては視覚的に邪魔だったりするかな、と感じました。個人的にはもう少しアニメーションは控えめであって欲しい…!
しかし、更新が盛んでSwift3にも対応済みということでこれから使うのは良いかもしれないですね。

Koloda

こちらはSwift製ですが、Swift3には未対応の模様。
Kolodaの特徴は、UITableViewライクなdelegate/dataSourceインタフェースを使っているところですね。

簡単な使い方説明

delegate, dataSource設定

Kolodaをimportし、UITableViewを使用するとき同様にdelegateとdataSourceを設定します。

ViewController
class ViewController: UIViewController {

    private let cardWidth = CGFloat(250)
    private let cardHeight = CGFloat(300)

    let kolodaView = KolodaView()

    let images = [UIImage(named: "Droid"), UIImage(named: "Chrome"), UIImage(named: "Azarashi"), UIImage(named: "Kitakamakura")]

    var dataSource = [UIView]()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .whiteColor()

        for image in images {
            let card = CardView(frame: CGRect(x: 0, y: 0, width: cardWidth, height: cardHeight))
            card.setImage(image!)
            dataSource.append(card)
        }

        kolodaView.frame = CGRect(x: 0, y: 0, width: cardWidth, height: cardHeight)
        kolodaView.center = self.view.center
        self.view.addSubview(kolodaView)

        kolodaView.dataSource = self // dataSource設定
        kolodaView.delegate = self // delegate設定

    }

上記コード内、kolodaViewが、カードを表示するベースとなるビューです。
その上に、dataSourceに入っているCardView達を置いていくようなイメージですね。

delegate, dataSourceプロトコルのメソッドを実装
extension ViewController: KolodaViewDelegate {
    func kolodaDidRunOutOfCards(koloda: KolodaView) {
        print("Out of stock!!")
    }

    func koloda(koloda: KolodaView, didSelectCardAtIndex index: UInt) {
        print("index \(index) has tapped!!")
    }
}

extension ViewController: KolodaViewDataSource {
    func kolodaNumberOfCards(koloda:KolodaView) -> UInt {
        return UInt(dataSource.count)
    }

    func koloda(koloda: KolodaView, viewForCardAtIndex index: UInt) -> UIView {
        return dataSource[Int(index)]
    }

    func koloda(koloda: KolodaView, viewForCardOverlayAtIndex index: UInt) -> OverlayView? {
        return nil
    }
}

これらのメソッドは、かなりUITableViewと似ていてメソッド名を読んだだけで意味がわかるものが多いですね。kolodaDidRunOutOfCards(koloda: KolodaView) は、スワイプを続けて行ってデータソース内のViewを全て表示しきった際に呼ばれるメソッドです。

ちなみに、UITableViewと同様にreloadData()メソッドも用意されているので、ある程度スワイプされたら追加でサーバから画像を取ってきて表示、ってのも下記のようにできます。

func koloda(koloda: KolodaView, didSwipeCardAtIndex index: UInt, inDirection direction: SwipeResultDirection) {
    if Int(index) == 5 {
        let card = CardView(frame: CGRect(x: 0, y: 0, width: cardWidth, height: cardHeight))
        card.setImage(UIImage(named: "Kitakamakura")!)
        datasource.append(card)
        kolodaView.reloadData()
    }
}

ちなみに上記は画像がスワイプされたら実行されるメソッドです。下記のように、方向によって挙動を変えるってことももちろんできます。

func koloda(koloda: KolodaView, didSwipeCardAtIndex index: UInt, inDirection direction: SwipeResultDirection) {
    switch direction {
    case .Right:
        print("Swiped to right!")
    case .Left:
        print("Swiped to left!")
    default:
        return
    }
}

動作確認

Koloda.gif

さっきとだいたい一緒ですねw

その他のdelegateメソッドたち

他にも下記のようなdelegateメソッド達が用意されています!

func koloda(koloda: KolodaView, allowedDirectionsForIndex index: UInt) -> [SwipeResultDirection] // スワイプを許可する方向を決める
func koloda(koloda: KolodaView, shouldSwipeCardAtIndex index: UInt, inDirection direction: SwipeResultDirection) -> Bool // スワイプ直前で呼ばれ、falseを設定するとスワイプできなくなる
func kolodaShouldApplyAppearAnimation(koloda: KolodaView) -> Bool // カード再読込時のアニメーションの有効/無効
func kolodaShouldMoveBackgroundCard(koloda: KolodaView) -> Bool // カードドラッグ中に移動距離に応じて後ろのカードを動かすか否か
func kolodaShouldTransparentizeNextCard(koloda: KolodaView) -> Bool // 後ろにあるカードを透過するか否か
func koloda(koloda: KolodaView, draggedCardWithPercentage finishPercentage: CGFloat, inDirection direction: SwipeResultDirection) // ドラッグする度に呼ばれるイベント
func kolodaDidResetCard(koloda: KolodaView) // カードをリセットした際に呼ばれる
func kolodaSwipeThresholdRatioMargin(koloda: KolodaView) -> CGFloat? // 中央からどこまで移動したらスワイプと判定するか
func koloda(koloda: KolodaView, didShowCardAtIndex index: UInt) // カードが表示された際に呼ばれる
func koloda(koloda: KolodaView, shouldDragCardAtIndex index: UInt ) -> Bool // ドラッグ開始時に呼ばれる。falseを設定するとカードが移動しなくなる

正直それどういうときに使うの?ってメソッドもありますが、なかなか充実していて一通りのことはできそうです。

Undo

ちなみにKolodaもUndo処理が行なえます。
KolodaViewのrevertAction()メソッドを呼ぶだけ!
しかしZLSwipeableViewと違い、スワイプしたカードが戻ってくるようなアニメーションにはなっておらず、Undo感が弱いです。ここは頑張って自分でカスタムしないとですね。。

個人的に思ったこと

インタフェースがUITableViewと似たような形なので、実装がやりやすいなと感じました。
ドラッグ中にオーバーレイする画像(下記動画参照)を設定するprotocolのメソッドも用意されていて、下記動画のようなアプリの実装が非常に簡単にできますね。
Koloda_v1_example_animation.gif

スター数が多いのもうなずけるライブラリです。
Swift3対応はよ…!(ブランチはあるみたいですね!)

最後に

どっちが優れている!というのもなかなか難しいし実装方針も違うので個人の好みもあると思いますが、ひとまず簡単なアプリで使ってみるとしたらKolodaの方がインタフェースもとっつきやすく、スムーズに実装ができるかと思います。ただし!Swift3を使う人は(現時点では)ZLSwipeableViewSwift1択ですかね!
自分もまだまだ使い込んでいるわけではないですが(個人ではObj-C製のMDCSwipeToChooseを使っていますw)、今回紹介したライブラリに置き換えて作ってみようかなと思ったりしてます。