EvernoteのトップメニューのようなスクロールアニメーションをするCollectionViewの作り方

エウレカでCouplesのiOS開発を担当しています、丹です。
iOS開発歴としては1年8ヶ月ほどで、まだまだひよっこですが、ブログで紹介したいと思います。


今回は、SwiftでEvernoteのような動きとMessages(iOSのデフォルトアプリ)の動きを合わせたアニメーションの実装方法を紹介します。Evernoteのトップメニューの動きはこちらです。

Evernoteのアニメーション

スクロールする度にビューとビューの間が伸縮します。僕はこの動きが好きで、ずっと意味もなく触っていたいなと思いました。布団の手触りを気にするように、アプリの手触りもこだわっていきたいですよね。


今回、主に使用するAPIはiOS7からのもので、特別最新ではないですが、日本語の情報も少なかったので紹介することにしました。言語はSwift2.1で最新です。Couplesでは7割ほどはすでにSwiftで書かれています。


ということで、早速実装方法いきます。

実装方法

このアニメーションを実装するために使用する主なクラスは、

の2つです。UIDynamicAnimatorはiOS7以上で使えます。
恐らく、Evernoteのトップ画面のメニューはUICollectionViewで実装されており(ナビゲーションバーっぽいビューも含め)、そのcollectionViewLayout: UICollectionViewLayoutをカスタマイズすることで、あの気持ち良いアニメーションを実現しています。MessagesはもちろんUICollectionViewですね。


UIDynamicAnimatorは物理学に則したアニメーションを実現するためのクラスです。UIDynamicBehaviorをUIDynamicAnimatorに追加することで様々なアニメーションを実現します。UIDynamicBehaviorには、衝突 (UICollisionBehavior)、重力 (UIGravityBehavior)、バネ (UISnapBehavior) などの種類があります。今回はUIAttachmentBehaviorを使用します。


UIAttachmentBehaviorは2つの物体の間の関係を定義することができるクラスです。2つの物体とは「ビューとビュー」と「ビューとアンカーポイント」の2種類があります。UIAttachmentBehaviorにはlength: CGFloatというプロパティがあり、2つの物体間の距離を変更できます。今回は「ビューとアンカーポイント」の方を使用します。

UICollectionViewFlowLayoutのサブクラスSpringFlowLayoutを作る

カスタムUICollectionViewFlowLayoutを作っていきます。今回はSpringFlowLayoutというクラス名にします。簡略化のために、Verticalのスクロールのみに対応します。

import Foundation
import UIKit
class SpringFlowLayout: UICollectionViewFlowLayout {
// この値が小さいほどアニメーションの振れ幅が大きくなります
// Evernoteのアニメーションに使用します。
// 大体10くらいが本家のアニメーションに近いです
var boundaryScrollResistanceFactor: CGFloat = 10
// この値が小さいほどアニメーションの振れ幅が大きくなります
// Messagesのアニメーションに使用します
// 大体500 - 2000くらいが妥当でしょうか
var scrollResistanceFactor: CGFloat = 1000
// この値が小さいほど、アニメーションが長く続きます
// 0.0 - 1.0までの値を取ります
var springDamping: CGFloat = 1.0
private var animator: UIDynamicAnimator!
override init() {
super.init()
self.configure()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.configure()
}
private func configure() {
// UICollectionViewFlowLayoutで初期化します
self.animator = UIDynamicAnimator(collectionViewLayout: self)
}
}
コア部分の実装
先に進む前にここで基本戦略です。


スクロールする度に以下を行います。
  • 見えている範囲のセルに対応するUIAttachmentBehaviorだけをUIDynamicAnimatorに追加する
  • セルの中心点を調整する
上記1はパフォーマンスのためです。初期化したときに、すべてのセルに対応するUIAttachmentBehaviorを追加しても良いのですが、セルの数が膨大な場合、レイアウトが遅くなる可能性があります。


また、上記2についてはcontentOffsetが上端、下端にあるときはEvernoteのアニメーション、それ以外はMessagesのアニメーションをするように実装します。
1. 見えている範囲のセルに対応するUIAttachmentBehaviorだけをUIDynamicAnimatorに追加する
これはprepareLayoutメソッド内で行います。
prepareLayoutメソッドは初回レイアウト時、collectionViewをスクロールしてレイアウトが変更される直前に呼ばれます。
private var visibleIndexPaths = Set()
private var addedBehaviors = [NSIndexPath: UIAttachmentBehavior]()
override func prepareLayout() {
super.prepareLayout()
guard let collectionView = self.collectionView else {
return
}
/*
ここにEvernoteのアニメーション用のコードが入ります。2で説明します。
*/
// 実際の見える範囲より、広げます
// こうすることで画面の端のセルのアニメーションが滑らかになります
let visibleRect = CGRectInset(CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size), 0, -100)
// visibleItems: [UICollectionViewLayoutAttributes]
guard let visibleItems = super.layoutAttributesForElementsInRect(visibleRect) else {
return
}
let visibleIndexPaths = visibleItems.map { $0.indexPath }
// 見えなくなったIndexPathに対応するUIAttachmentBehaviorを除きます
let noLongerVisibleIndexPaths = self.visibleIndexPaths.subtract(visibleIndexPaths)
for indexPath in noLongerVisibleIndexPaths {
if let behavior = self.addedBehaviors[indexPath] {
self.animator.removeBehavior(behavior)
self.addedBehaviors.removeValueForKey(indexPath)
}
}
// visibleIndexPathsを更新する
self.visibleIndexPaths = Set(visibleIndexPaths)
// 新しく見えるようになったIndexPathに対応するUIAttachmentBehaviorを追加します
for item in visibleItems {
// 既に追加済みのbehaviorをanimatorに追加するとクラッシュするので、ここでcontinue
if let _ = self.addedBehaviors[item.indexPath] {
continue
}
let behavior = UIAttachmentBehavior(item: item, attachedToAnchor: item.center)
behavior.length = 0
behavior.damping = self.springDamping
behavior.frequency = 1.0
self.animator.addBehavior(behavior)
self.addedBehaviors[item.indexPath] = behavior
}
}
2. セルの中心点を調整する
Evernoteのアニメーション
Evernoteのアニメーションは、引っ張った分だけセルとセルのマージンが広がるアニメーションです。実装はprepareLayoutの中に追記します。上端でのアニメーションと下端でのアニメーションを分けて実装しています。
override func prepareLayout() {
super.prepareLayout()
guard let collectionView = self.collectionView else {
return
}
// 初回レイアウトを避けるため
if self.animator.behaviors.count != 0 {
let contentOffset = collectionView.contentOffset.y
let contentSize = collectionView.contentSize.height
let collectionViewSize = collectionView.bounds.size.height
if contentOffset < 0 { 
// 上端でのアニメーション
let distanceFromEdge = fabs(contentOffset) // 上端からの距離 
let offset = distanceFromEdge / self.boundaryScrollResistanceFactor // セル間の広がる距離
for behavior in self.animator.behaviors {
guard let behavior = behavior as? UIAttachmentBehavior, let item = behavior.items.first as? UICollectionViewLayoutAttributes else {
continue 
}
// 1* 中心点を調整します 
item.center.y = self.itemSize.height / 2 + offset + (self.minimumInteritemSpacing + self.itemSize.height + offset) * CGFloat(item.indexPath.item) - distanceFromEdge
// これを呼ぶことで更新されます 
self.animator.updateItemUsingCurrentState(item)
}
return
} else if contentOffset + collectionViewSize > contentSize {
// 下端でのアニメーション
let distanceFromEdge = fabs(contentOffset + collectionViewSize - contentSize)
let offset = distanceFromEdge / self.boundaryScrollResistanceFactor
let itemCount = collectionView.numberOfItemsInSection(0)
for behavior in self.animator.behaviors {
guard let behavior = behavior as? UIAttachmentBehavior, let item = behavior.items.first as? UICollectionViewLayoutAttributes else {
continue
}
item.center.y = contentSize - (self.itemSize.height / 2 + offset + (self.minimumInteritemSpacing + self.itemSize.height + offset) * CGFloat(itemCount - item.indexPath.item - 1) - distanceFromEdge)
self.animator.updateItemUsingCurrentState(item)
}
return
}
}
... // 続きは1で説明したコード。初回レイアウト、中間位置をスクロールしている時に呼ばれます。
}
1*の中心点の位置の計算ですが、素直にcollectionViewの上端からの距離を計算しています。distanceFromEdgeを引くことで通常のスクロールによるセルの移動を相殺しています。
Messagesのアニメーション
func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool
がcollectionViewをドラッグしている際に呼ばれることを利用して、その中でセルの中心点を調整します。レイアウトを無効にしないので、返り値はfalseにします。
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
guard let collectionView = self.collectionView else {
return false
}
let contentOffset = collectionView.contentOffset.y
let contentSize = collectionView.contentSize.height
let collectionViewSize = collectionView.bounds.size.height
if contentOffset < 0 || contentOffset + collectionViewSize > contentSize {
// contentOffsetが上端、下端にあるので無視します
return false
}
/*
Messagesのアニメーションをする
*/
// 前回のレイアウト時からスクロールした距離を計算します
let scrollDistance = newBounds.origin.y - collectionView.bounds.origin.y
// タッチしている場所を取得します
let touchLocation = collectionView.panGestureRecognizer.locationInView(collectionView)
for behavior in self.animator.behaviors {
if let behavior = behavior as? UIAttachmentBehavior, let item = behavior.items.first {
let distanceFromTouch = fabs(touchLocation.y - item.center.y)
let scrollResistance = distanceFromTouch / self.scrollResistanceFactor
let offset = scrollDistance * scrollResistance
item.center.y += offset
self.animator.updateItemUsingCurrentState(item)
}
}
return false
}
Messagesのアニメーションの実装方法はこちらのobjc.ioの記事を参考にしています。
3. その他
上記の実装に加えて、layoutAttributesを返すメソッドをオーバーライドします。
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return self.animator.itemsInRect(rect) as? [UICollectionViewLayoutAttributes]
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return self.animator.layoutAttributesForCellAtIndexPath(indexPath)
}
SpringFlowLayoutを試す
UICollectionViewを持つUIViewControllerで実際に使う方法です。
とっても簡単です。
override func viewDidLoad() {
super.viewDidLoad()
let flowLayout = SpringFlowLayout()
flowLayout.scrollDirection = .Vertical
flowLayout.scrollResistanceFactor = 1000 // とりあえずデフォルト値を入れてる
flowLayout.springDamping = 1.0
self.collectionView.collectionViewLayout = flowLayout
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.springFlowLayout.itemSize = CGSize(width: self.collectionView.bounds.size.width, height: 44)
}
実際の動きはこちらです!パラメーターはすべてデフォルト値を使ってます。良い感じです。
最後に
今回はEvernoteのアニメーションをUIAttachmentBehaviorのアンカーポイントを使って実現しましたが、他のitemとのlengthを調整することで実現できるかもしれません。
また、UIDynamicBehaviorで衝突や重力の動きも再現できるので、使ってみたいなと思ってます。実際のアプリに使用できる機会はあまりないと思いますが、ピンポイントでリッチなアニメーションを実現したいときには使えますね。


サンプルコードはこちらのレポジトリにあります。eure/SpringFlowLayoutExample
参考
UICollectionViewFlowLayout
UIDynamicAnimator
UIDynamicBehavior
UIAttachmentBehavior
objc.io / UICollectionView + UIKit Dynamics
Like what you read? Give eureka_developers a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.