iosswiftuicollectionviewuiscrollviewspacing

mimic photos app collectionview spacing when not scrolling


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)


Solution

  • 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.