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

# はじめに

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

# 例

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

![Dec-02-2018 22-31-32.gif](https://qiita-image-store.s3.amazonaws.com/0/121719/5ef0f94f-bb50-eb1a-06a1-719576a3e2a6.gif)

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

![Dec-02-2018 22-33-20.gif](https://qiita-image-store.s3.amazonaws.com/0/121719/4a268bbe-1e81-db08-a262-01e28e5dd5cb.gif)

# UICollectionViewLayout のカスタマイズ

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

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

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

- [prepare(forCollectionViewUpdates:)](https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617784-prepare)
- [targetContentOffset(forProposedContentOffset:)](https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617724-targetcontentoffset)
- [finalLayoutAttributesForDisappearingItem(at:)](https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617740-finallayoutattributesfordisappea)
- [initialLayoutAttributesForAppearingItem(at:)](https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617789-initiallayoutattributesforappear)
- [finalizeCollectionViewUpdates()](https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617787-finalizecollectionviewupdates)

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

## prepare(forCollectionViewUpdates:)

```swift: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:)

```swift: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
    }
```

<img src="https://qiita-image-store.s3.amazonaws.com/0/121719/0a977819-3f77-a0cb-2a34-d95e8153f958.jpeg" width="600">

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

## finalizeCollectionViewUpdates()

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

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

# 終わりに

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