##はじめに
こんにちは、nirazoです。iOSエンジニアをしてます。
突然ですが皆さん、TinderUIはお好きでしょうか?
TinderUIとは、マッチングアプリのTinderで採用されている、好き、嫌いをカードを左右にスワイプすることによって表現するUIです。
TinderUIが正式名称なのか正直定かでは無いですが、各種記事等でよく見るのでこの名称で呼ぶことにします。
Tinderは海外製のアプリですが、日本のアプリでも、転職関連のサービス等でたまに採用されていたりします。
気づけばTinderのリリースから4年くらい経ち、新しさもすっかり薄れてしましましたが、個人的には良いUIだなーと思っているので、皆もっと使っていこうぜ!という思いを込めて筆を取りました。
TinderUIの良さ
とは言え何が良いんだって話しがあるかと思います。
TinderUIには、
- 認知的負荷の最小化
- ゲーミフィケーション
- 親指だけで操作可能
といった特徴があり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を使っています。
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
}
}
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.
}
}
動作確認
うん、問題無いですね。
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を設定します。
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
}
}
動作確認
さっきとだいたい一緒ですね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のメソッドも用意されていて、下記動画のようなアプリの実装が非常に簡単にできますね。
スター数が多いのもうなずけるライブラリです。
Swift3対応はよ…!(ブランチはあるみたいですね!)
最後に
どっちが優れている!というのもなかなか難しいし実装方針も違うので個人の好みもあると思いますが、ひとまず簡単なアプリで使ってみるとしたらKolodaの方がインタフェースもとっつきやすく、スムーズに実装ができるかと思います。ただし!Swift3を使う人は(現時点では)ZLSwipeableViewSwift1択ですかね!
自分もまだまだ使い込んでいるわけではないですが(個人ではObj-C製のMDCSwipeToChooseを使っていますw)、今回紹介したライブラリに置き換えて作ってみようかなと思ったりしてます。