Skip to content

Instantly share code, notes, and snippets.

@fumiyasac
Last active January 12, 2019 09:44
Show Gist options
  • Save fumiyasac/b12739a043d52cbee0731c3318d4b04b to your computer and use it in GitHub Desktop.
Save fumiyasac/b12739a043d52cbee0731c3318d4b04b to your computer and use it in GitHub Desktop.
Tinder風なUIを実装する際のアイデアと実装例紹介 ref: https://qiita.com/fumiyasac@github/items/c68b7ce812bf3ef48a67
// 楽天レシピ別カテゴリランキングのAPIキー ※各自取得をお願いします。
static let API_KEY_RAKUTEN_RECIPE_RANKING = ""
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
}
}
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)
}
}
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 ?? ""
}
}
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(
// 通信成功時:
success: { (data: Dictionary) 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)
},
// 通信失敗時:
fail: { (error: Error?) in
// 通信失敗時の処理をプロトコルを適用したViewController側で行う
self.presenter.showErrorMessage()
}
)
}
}
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
}
}
class TinderCardDefaultSettings: TinderCardSetting {
// ・・・(省略)・・・
// MARK: - TinderCardSetViewSettingプロトコルで定義した変数
static var cardSetViewWidth: CGFloat = 300
static var cardSetViewHeight: CGFloat = 320
// ・・・(以下カード用Viewのデザイン設定に関する定数値)・・・
}
protocol TinderCardSetting {
// MARK: - Static Properties
// カード用View高さ
static var cardSetViewWidth: CGFloat { get }
// カード用View幅
static var cardSetViewHeight: CGFloat { get }
// ・・・(以下カード用Viewのデザイン設定に関する変数を定義する)・・・
}
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()
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment