Help us understand the problem. What is going on with this article?

UICollectionView の deleteItems 時に contentOffset をいい感じに調整する

More than 1 year has passed since last update.

はじめに

2018 年の年の瀬に恐縮ですが、UICollectionView の話です。deleteItems 等で contentSize が変わる場合に、ついでに contentOffset を調整する方法について。

deleteItems(at:) や reloadItems(at:) 等で UICollectionView の Item 部分更新を行い contentSize が変わる場合、デフォルトのままでは原則 contentOffset は変わりません。
下の図のように、いま見ている表示領域より下の部分が縮む場合は特に気になりませんが、上の部分が縮む場合は(縮む長さにもよりますが)いままで見ていた要素が表示領域外に行ってしまい、現在位置が一瞬わからなくなってしまいます。

Dec-02-2018 22-31-32.gif

下の図のように、表示領域より上の部分が縮んでも現在位置を見失わないよう、contentOffset を調整したいと思います。

Dec-02-2018 22-33-20.gif

UICollectionViewLayout のカスタマイズ

deleteItems(at:) を実行した後に scrollToItem() 等で contentOffset を変更する方法もありますが、削除のアニメーションが完了した後に移動することになるので違和感を覚えます。削除のアニメーションで同時に contentOffset を変更するには、UICollectionViewLayout をカスタマイズする必要があります。

Item 更新時に呼び出されるメソッド

Item 更新時には UICollectionViewLayout の下記メソッドが呼び出されます。

今回の目的の実現には、targetContentOffset(forProposedContentOffset:) をオーバーライドし、アニメーション完了時の contentOffset を返すようにしてあげればよいです。

prepare(forCollectionViewUpdates:)

CustomFlowLayout.swift
class CustomFlowLayout: UICollectionViewFlowLayout {

    var minIndexPath: IndexPath?
    var maxIndexPath: IndexPath?

    override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
        print("prepare(forCollectionViewUpdates:)")
        super.prepare(forCollectionViewUpdates: updateItems)
        guard
            let minIndexPath = updateItems.compactMap({ $0.indexPathBeforeUpdate ?? $0.indexPathAfterUpdate }).min(),
            let maxIndexPath = updateItems.compactMap({ $0.indexPathBeforeUpdate ?? $0.indexPathAfterUpdate }).max()
            else { return }
        print("    min index path:", minIndexPath)
        print("    max index path:", maxIndexPath)
        self.minIndexPath = minIndexPath
        self.maxIndexPath = maxIndexPath
    }
    ...
}

prepare(forCollectionViewUpdates:) では更新対象の Item の情報が渡ってきます。ここで更新範囲を把握するために、更新対象の IndexPath の最小と最大を保持しておきます。

targetContentOffset(forProposedContentOffset:)

CustomFlowLayout.swift
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        print("targetContentOffset(forProposedContentOffset:)")
        let targetContentOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
        guard let collectionView = collectionView,
            let minIndexPath = minIndexPath,
            let maxIndexPath = maxIndexPath,
            let minAttributes = layoutAttributesForItem(at: minIndexPath),
            let maxAttributes = layoutAttributesForItem(at: maxIndexPath)
            else { return targetContentOffset }

        let viewTop = collectionView.contentOffset.y
        let viewBottom = viewTop + collectionView.frame.size.height
        print("        view range: \(viewTop) - \(viewBottom)")

        let updateTop = minAttributes.frame.origin.y
        let updateBottom = maxAttributes.frame.origin.y
        print("      update range: \(updateTop) - \(updateBottom)")

        let currentHeight = collectionView.contentSize.height
        let newHeight = collectionViewContentSize.height
        print("    current height:", currentHeight)
        print("        new height:", newHeight)

        if currentHeight > newHeight,
            viewBottom > updateBottom {
            let diff = currentHeight - newHeight
            return CGPoint(x: targetContentOffset.x,
                           y: max(collectionView.contentOffset.y - diff, 0))
        }
        return targetContentOffset
    }

こんなイメージで。すごい雑ですが、表示領域より上の部分が縮んだという判定は viewBottom > updateBottom で行なっています。判定が真の場合に縮んだ分だけ contentOffset.y を上にずらした CGPoint を返しています。
ちなみに、更新前(現在)の contentSize は collectionView.contentSize で、更新後の contentSize は collectionViewContentSize でそれぞれ取得できます。

finalizeCollectionViewUpdates()

CustomFlowLayout.swift
    override func finalizeCollectionViewUpdates() {
        print("finalizeCollectionViewUpdates()")
        super.finalizeCollectionViewUpdates()
        minIndexPath = nil
        maxIndexPath = nil
    }

一応最終処理も忘れずに。

終わりに

今回作成したプロジェクトを下記リポジトリにあげています。
https://github.com/imamurh/CollectionViewSample

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away