Search code examples
iosswiftuicollectionviewlayoutuicollectionviewcompositionallayout

UICollectionView with Compositional Layout disappears cells with frame still on screen


Trying to get a "sticky header" that will orthogonally scroll with the section, but only partially, leaving the trailing end exposed until scrolled backwards.

Headers don't work as they don't scroll, so I've decided to just make it work as another cell in the section.

Demo Code: https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views (Download the project)

Open: Modern Collection Views > Compositional Layout > Advanced layouts View Controllers > OrthogonalScrollBehaviorViewController.swift

Replace

func createLayout() -> UICollectionViewLayout {
...
}

With

    func createLayout() -> UICollectionViewLayout {

        let config = UICollectionViewCompositionalLayoutConfiguration()
        config.interSectionSpacing = 20

        let layout = UICollectionViewCompositionalLayout(sectionProvider: {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            guard let sectionKind = SectionKind(rawValue: sectionIndex) else { fatalError("unknown section kind") }

            let leadingItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(0.5)))

            let orthogonallyScrolls = sectionKind.orthogonalScrollingBehavior() != .none
            let containerGroup = NSCollectionLayoutGroup.horizontal(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(0.4)),
                subitems: [leadingItem])
            let section = NSCollectionLayoutSection(group: containerGroup)
            section.orthogonalScrollingBehavior = sectionKind.orthogonalScrollingBehavior()
            section.visibleItemsInvalidationHandler = { (items, offset, env) in
                let buffer: CGFloat = 50
                for item in items {
                    if item.indexPath.item == 0 {
                        item.zIndex = 25
                        let w = item.frame.width
                        if offset.x >= (w - buffer) {
                            item.transform = CGAffineTransform(translationX: offset.x - (w - buffer), y: 0)
                        } else {
                            item.transform = .identity
                        }
                    } else {
                        item.zIndex = -item.indexPath.item
                    }
                }
            }

            let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .estimated(44)),
                elementKind: OrthogonalScrollBehaviorViewController.headerElementKind,
                alignment: .top)
            section.boundarySupplementaryItems = [sectionHeader]
            return section

        }, configuration: config)
        return layout
    }

As you can see it works perfectly RIGHT UP to where the cell is now "offscreen" according to its bounds, even though it is still clearly visible on screen.

I have also tried using a custom UICollectionViewCompositionalLayout that makes sure the IndexPath has an attribute in layoutAttributesForElements(in rect: CGRect), exactly the same results: As soon as the 'bounds' are offscreen the cell is removed even if the frame is very clearly still on screen.

Additionally, the line

item.transform = CGAffineTransform(translationX: offset.x - (w - buffer), y: 0)

can be anything that is functionally equivalent (the only other thing I've tried is moving the center) but the results are the same.


Solution

  • You can hack it with a header, a contentView + offset in the header, override hitTest on the header, section.visibleItemsInvalidationHandler to update the offset, and add a tapGesture on the view containing the collectionView to allow tapping the header (since its hitTest needs to always be nil)

    Header Looks like this:

    class MagicHeader: UICollectionReusableView {
        static let reuseIdentifier = "text-cell-reuse-identifier3"
        static let elementKind = "magic-header-kind"
        var contentView: UIView!
        var leadingC: NSLayoutConstraint!
    
        var offset: CGFloat = 0 {
            didSet {
                leadingC.constant = -offset
            }
        }
        
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            return nil
        }
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            contentView = UIView(frame: frame)
            addSubview(contentView)
            contentView.translatesAutoresizingMaskIntoConstraints = false
            leadingC = contentView.leadingAnchor.constraint(equalTo: self.leadingAnchor)
            NSLayoutConstraint.activate([
                contentView.widthAnchor.constraint(equalTo: self.widthAnchor),
                contentView.heightAnchor.constraint(equalTo: self.heightAnchor),
                contentView.topAnchor.constraint(equalTo: self.topAnchor),
                leadingC
            ])
            //configure() -- This is from the example code above, not 'necessary' for solution
        }
    // Other code here
    }
    

    Section Provider:

                    let headerW: CGFloat = 200
                    let headerSize = NSCollectionLayoutSize(widthDimension: .absolute(headerW), heightDimension: .absolute(80))
                    
                    let header = NSCollectionLayoutBoundarySupplementaryItem(
                        layoutSize: headerSize,
                        elementKind: MagicHeader.elementKind,
                        alignment: .leading,
                        absoluteOffset: CGPoint(x: -headerW, y: 0))
                    header.pinToVisibleBounds = true
                    header.zIndex = 2
                    
                    let contribItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(80), heightDimension: .absolute(80)))
                    
                    let group = NSCollectionLayoutGroup.horizontal(
                        layoutSize: NSCollectionLayoutSize(
                            widthDimension: .absolute(80),
                            heightDimension: .absolute(80)),
                        subitems: [contribItem])
                    let section = NSCollectionLayoutSection(group: group)
                    section.boundarySupplementaryItems = [header]
                    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: headerW, bottom: 0, trailing: 0)
                    section.visibleItemsInvalidationHandler = { (items, offset, env) in
                        for item in items {
                            if item.representedElementKind == MagicHeader.elementKind {
                                guard let headerElement = self.collectionView.supplementaryView(forElementKind: item.representedElementKind!, at: item.indexPath) as? MagicHeader
                                }
                                let buffer: CGFloat = 20
                                if offset.x >= (headerW - buffer) {
                                    headerElement.offset = (headerW - buffer)
                                } else {
                                    headerElement.offset = offset.x
                                }
                            }
                        }
                    }
    
                    section.orthogonalScrollingBehavior = .continuous
    
                    return section
    

    and then finally

    class OrthogonalScrollBehaviorViewController {
    -- code
        override func viewDidLoad() {
            super.viewDidLoad()
            navigationItem.title = "Orthogonal Section Behaviors"
            configureHierarchy()
            configureDataSource()
            kingTap = HeaderTapGR(target: self, action: #selector(OrthogonalScrollBehaviorViewController.handleTap(_:)))
            view.addGestureRecognizer(kingTap)
            kingTap.delegate = self
        }
    }
    
    extension OrthogonalScrollBehaviorViewController: UIGestureRecognizerDelegate {
        func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
            for subv in collectionView.subviews {
                if let header = subv as? MagicHeader {
                    let pt = gestureRecognizer.location(in: header)
                    if header.contentView.frame.contains(pt) {
                        if let headerGR = gestureRecognizer as? HeaderTapGR {
                            headerGR.header = header
                        }
                        return true
                    }
                }
            }
            return false
        }
    }
    

    So basically the 'visibleItems' section thing computes the offset, updates the offset on the header cell. The MagicHeader never actually moves, it just shifts the internal contentView to look like it's moving.

    Since it's not moving it's going to gobble touch events so you have to tell it "never get touched." If you have hitTest 'hit' when the touch event is in the bounds of the 'contentView' it wont' scroll, so that's up to you. If you don't have it return nil it's probably easier to do the tapping.

    If it's always nil it's never going to recv tap events, so if you want to be able to tap the cell you need to look for the touch event at the top (VC containing the collectionV) and then only fire that tap when it's in the contentView's bounds otherwise you'll prevent 'collectionView(_, didSelectItemAt:...)' from firing.

    All because someone is using cell.bounds instead of cell.frame to determine visibility.