はじめに
2018 年の年の瀬に恐縮ですが、UICollectionView の話です。deleteItems 等で contentSize が変わる場合に、ついでに contentOffset を調整する方法について。
例
deleteItems(at:) や reloadItems(at:) 等で UICollectionView の Item 部分更新を行い contentSize が変わる場合、デフォルトのままでは原則 contentOffset は変わりません。
下の図のように、いま見ている表示領域より下の部分が縮む場合は特に気になりませんが、上の部分が縮む場合は(縮む長さにもよりますが)いままで見ていた要素が表示領域外に行ってしまい、現在位置が一瞬わからなくなってしまいます。
下の図のように、表示領域より上の部分が縮んでも現在位置を見失わないよう、contentOffset を調整したいと思います。
UICollectionViewLayout のカスタマイズ
deleteItems(at:) を実行した後に scrollToItem() 等で contentOffset を変更する方法もありますが、削除のアニメーションが完了した後に移動することになるので違和感を覚えます。削除のアニメーションで同時に contentOffset を変更するには、UICollectionViewLayout をカスタマイズする必要があります。
Item 更新時に呼び出されるメソッド
Item 更新時には UICollectionViewLayout の下記メソッドが呼び出されます。
- prepare(forCollectionViewUpdates:)
- targetContentOffset(forProposedContentOffset:)
- finalLayoutAttributesForDisappearingItem(at:)
- initialLayoutAttributesForAppearingItem(at:)
- finalizeCollectionViewUpdates()
今回の目的の実現には、targetContentOffset(forProposedContentOffset:) をオーバーライドし、アニメーション完了時の contentOffset を返すようにしてあげればよいです。
prepare(forCollectionViewUpdates:)
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:)
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()
override func finalizeCollectionViewUpdates() {
print("finalizeCollectionViewUpdates()")
super.finalizeCollectionViewUpdates()
minIndexPath = nil
maxIndexPath = nil
}
一応最終処理も忘れずに。
終わりに
今回作成したプロジェクトを下記リポジトリにあげています。
https://github.com/imamurh/CollectionViewSample