Please use this gif as reference for expected result
Work is in progress and public in this github
I'm using default UICollectionViewFlowLayout
and set custom sizing for the selected cell when scrolling is not in motion. What can I do to mimic the additional spacing between the selected cell and its neighbours?
If I understand correctly, to have different spacing between cells, I'll have to write custom subclass of UICollectionViewLayout. however, this spacing is related to scrolling behaviour, and with those mixed together, I'm clueless about how to write the implementation. Some code from the public github i'm working on:
private func setupCollectionView() {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumInteritemSpacing = itemSpacing
let sideInset = (view.bounds.width - 44) / 2 // Ensure first and last items center-align
layout.sectionInset = UIEdgeInsets(top: 0, left: sideInset, bottom: 0, right: sideInset)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
// ...
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
if indexPath == collectionView.indexPathsForSelectedItems?.first {
if !isScrollingInMotion {
return .init(width: 44, height: 44)
}
}
return .init(width: 32, height: 44)
}
here I'm using isScrollingInMotion
to check if the collectionView is scrolling. it will becomes true when scrollViewDidScroll
is called, and becomes false when scrollViewDidEndDecelerating
or scrollViewDidEndDragging
is called. it also track if the scroll is from user panGesture or from another reason (e.g. select the item directly caused the scrollview to center the cell)
Thanks to the suggestion from @matt, I've managed to increase the spacing as expected, using 2 different layouts for 2 different states: scrolling and static.
private var isScrollingInMotion = false { didSet {
guard isScrollingInMotion != oldValue else { return }
if isScrollingInMotion {
collectionView.collectionViewLayout = photoViewerScrollingLayout
} else {
collectionView.collectionViewLayout = photoViewerStaticLayout
}
}}
private let photoViewerScrollingLayout = UICollectionViewFlowLayout()
private lazy var photoViewerStaticLayout = PhotoViewerCollectionViewLayout(scrollingLayout: photoViewerScrollingLayout)
private func setupCollectionView() {
photoViewerScrollingLayout.scrollDirection = .horizontal
photoViewerScrollingLayout.minimumInteritemSpacing = itemSpacing
let sideInset = (view.bounds.width - 44) / 2 // Ensure first and last items center-align
photoViewerScrollingLayout.sectionInset = UIEdgeInsets(top: 0, left: sideInset, bottom: 0, right: sideInset)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: photoViewerStaticLayout)
my layout attribute:
/// use when scrolling is not in motion
final class PhotoViewerCollectionViewLayout: UICollectionViewFlowLayout {
init(scrollingLayout: UICollectionViewFlowLayout) {
super.init()
scrollDirection = scrollingLayout.scrollDirection
minimumInteritemSpacing = scrollingLayout.minimumInteritemSpacing
sectionInset = scrollingLayout.sectionInset
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
guard let collectionView = collectionView else { return attributes }
guard let selectedIndexPath = collectionView.indexPathsForSelectedItems?.first else { return attributes }
let updatedAttributes = attributes.map { $0.copy() as! UICollectionViewLayoutAttributes }
let itemSpacing = minimumInteritemSpacing + 5
for attribute in updatedAttributes {
if attribute.indexPath.item > selectedIndexPath.item {
// Custom item on the right
attribute.frame.origin.x += itemSpacing
} else if attribute.indexPath.item == selectedIndexPath.item {
attribute.size.width = attribute.size.height
} else {
// Custom item on the left
attribute.frame.origin.x -= itemSpacing
}
}
return updatedAttributes
}
}
the animation looks fine (a little clutched but acceptable)
private func snapSelectedCenter() {
guard !isUserScrolling else { return }
guard let indexPath = collectionView.indexPathsForSelectedItems?.first else { return }
UIView.animate(withDuration: 0.2) {
if !self.isScrollingInMotion {
self.collectionView.collectionViewLayout.invalidateLayout()
}
self.isScrollingInMotion = false
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
} completion: { _ in
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isUserScrolling = true
UIView.animate(withDuration: 0.2) {
self.isScrollingInMotion = true
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
Now, there's only an issue with the default minimumInteritemSpacing
:
if itemSpacing: CGFloat
is not 8, (e.g. 0), when I'm scrolling (userBeginDragging), the spacing suddenly being set to a default value (8) instead of my preset number (e.g. 0). Maybe I should subclass even more? I'm not sure...
EDIT
I've found the reason for the weird spacing: the collection used the minimumLineSpacing
instead of expecting minimumInteritemSpacing
. I've changed to use only 1 layout for both states, custom the spacing (minimumLineSpacing
and minimumInteritemSpacing
) to fit my needs.
now it's pretty smooth, working as expected. I've updated public git repo if anyone want to see the final solution.
Note: it will break when changing orientation, but I don't plan to fix that.