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)
    }
}

コア部分の実装

先に進む前にここで基本戦略です。

スクロールする度に以下を行います。

  1. 見えている範囲のセルに対応するUIAttachmentBehaviorだけをUIDynamicAnimatorに追加する
  2. セルの中心点を調整する

上記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)
}

実際の動きはこちらです!パラメーターはすべてデフォルト値を使ってます。良い感じです。

SpringFlowLayoutのアニメーション

最後に

今回はEvernoteのアニメーションをUIAttachmentBehaviorのアンカーポイントを使って実現しましたが、他のitemとのlengthを調整することで実現できるかもしれません。
また、UIDynamicBehaviorで衝突や重力の動きも再現できるので、使ってみたいなと思ってます。実際のアプリに使用できる機会はあまりないと思いますが、ピンポイントでリッチなアニメーションを実現したいときには使えますね。

サンプルコードはこちらのレポジトリにあります。eure/SpringFlowLayoutExample

参考

UICollectionViewFlowLayout
UIDynamicAnimator
UIDynamicBehavior
UIAttachmentBehavior
objc.io / UICollectionView + UIKit Dynamics

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

大規模サービスあるある、”属人化”解消のための開発体制パターン

初心者歓迎! Elastic Stack 5.0 を使ってみる – 前編 –