Search code examples
iosswiftxcodeuicollectionviewuicollectionviewcompositionallayout

How to create a Stretchy Header for UICollectionViewCompositionalLayout with multiple sections?


This is my first time implementing UICollectionViewCompositionalLayout and im confused on how to implement a Stretchy Header here. Right now I have just modified the dataSource.supplementaryViewProvider function to include my custom HeaderView but I don't know how to make it attach to top. I did find some code for other types of collectionView layouts but those don't work with UICollectionViewCompositionalLayout. For other layouts I found that I need to override this override func layoutAttributesForElements(in rect: CGRect) but where and how? I would like to know a method from the scratch. Below is my method which does not work at all with UICollectionViewCompositionalLayout. This is how im creating my Header:

class StretchyCollectionHeaderView: UICollectionReusableView {
    static let reuseIdentifier = "stretchyCollectionHeaderView-reuse-identifier"
    
    let imageView: UIImageView = {
        let iv = UIImageView(image: UIImage(named: "HeaderHomePage"))
        iv.contentMode = .scaleAspectFill
        return iv
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // custom code for layout
        
        backgroundColor = .red
        
        addSubview(imageView)
        imageView.fillSuperview()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
}

Im using UICollectionReusableView instead of UIView because all section headers are passed as UICollectionReusableView. Im using an extension which will connect imageView's bottom to header view and for other constraints, I did not include that because I think it isn't even used which I will come back to at the end of explanation.

This is my Layout for CollectionViewLayout with Stretchy Header:

class StretchyHeaderLayout: UICollectionViewCompositionalLayout {

    // we want to modify the attributes of our header component somehow
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        
        let layoutAttributes = super.layoutAttributesForElements(in: rect)
        
        layoutAttributes?.forEach({ (attributes) in
            
            if attributes.representedElementKind == UICollectionView.elementKindSectionHeader && attributes.indexPath.section == 0 {
                
                guard let collectionView = collectionView else { return }
                
                let contentOffsetY = collectionView.contentOffset.y
                print(contentOffsetY)
                
                if contentOffsetY > 0 {
                    return
                }
                
                let width = collectionView.frame.width
                
                let height = attributes.frame.height - contentOffsetY
                
                // header
                attributes.frame = CGRect(x: 0, y: contentOffsetY, width: width, height: height)
                
            }
            
        })
        
        return layoutAttributes
    }
    
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }
    
}

Then I simply register all cells and supplementary views. Then im setting up my collectionViewLayout like this:

func generateLayout() -> UICollectionViewLayout {
        let layout = StretchyHeaderLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            let isWideView = layoutEnvironment.traitCollection.horizontalSizeClass == .regular
            let sectionLayoutKind = Section.allCases[sectionIndex]
            switch (sectionLayoutKind) {
            case .locationTab: return self.generateLocationLayout(isWide: isWideView)
            case .selectCategory: return self.generateCategoriesLayout()
            case .valueAddedServices: return self.generatValueAddedServicesLayout(isWide: isWideView)
            }
        }
        return layout
    }

im setting layout = StretchyHeaderLayout as this was the only possible way I could think of adding Stretchy Header layout.

And finally this is how im setting up my section headers:

dataSource.supplementaryViewProvider = { (
            collectionView: UICollectionView,
            kind: String,
            indexPath: IndexPath) -> UICollectionReusableView? in
            
            if indexPath.section == 0 {
                guard let supplementaryView = collectionView.dequeueReusableSupplementaryView(
                  ofKind: kind,
                  withReuseIdentifier: StretchyCollectionHeaderView.reuseIdentifier,
                  for: indexPath) as? StretchyCollectionHeaderView else { fatalError("Cannot create header view") }

                supplementaryView.imageView.image = UIImage(named: "HeaderHomePage")
                return supplementaryView
            }
            else {
                guard let supplementaryView = collectionView.dequeueReusableSupplementaryView(
                  ofKind: kind,
                  withReuseIdentifier: HeaderView.reuseIdentifier,
                  for: indexPath) as? HeaderView else { fatalError("Cannot create header view") }

                supplementaryView.label.text = Section.allCases[indexPath.section].rawValue
                return supplementaryView
            }
        }

Here im just using StretchyCollectionHeaderView for first section and for others im using another HeaderView which just contains a label.

What I think is happening is because of above function, its just setting header view with StreatchyCollectionHeaderView for first section but not accessing any code inside StretchyHeaderLayout and thus not sticking to top. Unlike UITableView, we cannot attach a CollectionView header instead of adding section header or in my case section header with image for first section and attach to top? How to create a stretchy header for UICollectionViewCompositionalLayout properly?


Solution

  • I found an answer by modifying some code I found for StretchyTableHeaderView. I will try to explain what I did in short before adding the code below. So first I simply created CollectionViewReusableView like for any SupplementaryView you create for CollectionViews. Actually I just found this code for stretchy TableViewHeader, I just converted it to UICollectionReusableView. But this header view contains a scrollViewDidScroll function which manipulates the bottom constraints for your containerView and imageView and also manipulates the height of imageView so it increases with offset of the scrollView.

    After that you just register this to your collectionView, pass this as supplementaryView for First Section and give it a height while creating the layout. And finally in scrollViewDidScroll delegate method, look for your supplementary view and if found, just call the scrollViewDidScroll function inside your header.

    This is the StretchHeaderCollectionResulableView class:

    final class StretchyCollectionHeaderView: UICollectionReusableView {
        static let reuseIdentifier = "stretchy-homePage-header-view-reuse-identifier"
        
        public let imageView: UIImageView = {
            let imageView = UIImageView()
            imageView.clipsToBounds = true
            imageView.contentMode = .scaleAspectFill
            return imageView
        }()
        
        private var imageViewHeight = NSLayoutConstraint()
        private var imageViewBottom = NSLayoutConstraint()
        private var containerView = UIView()
        private var containerViewHeight = NSLayoutConstraint()
        
        // MARK: - Init
        override init(frame: CGRect) {
            super.init(frame: frame)
            createViews()
            setViewConstraints()
        }
        
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
        }
        
        /// Create Subviews
        private func createViews() {
            addSubview(containerView)
            containerView.addSubview(imageView)
        }
        
        /// Setup View Constraints
        func setViewConstraints() {
            NSLayoutConstraint.activate([
                widthAnchor.constraint(equalTo: containerView.widthAnchor),
                centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
                heightAnchor.constraint(equalTo: containerView.heightAnchor)
            ])
            
            containerView.translatesAutoresizingMaskIntoConstraints = false
            
            containerView.widthAnchor.constraint(equalTo: imageView.widthAnchor).isActive = translatesAutoresizingMaskIntoConstraints
            containerViewHeight = containerView.heightAnchor.constraint(equalTo: self.heightAnchor)
            containerViewHeight.isActive = true
            
            imageView.translatesAutoresizingMaskIntoConstraints = false
            imageViewBottom = imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
            imageViewBottom.isActive = true
            imageViewHeight = imageView.heightAnchor.constraint(equalTo: containerView.heightAnchor)
            imageViewHeight.isActive = true
        }
        
        /// Notify View of scroll change from container
        public func scrollviewDidScroll(scrollView: UIScrollView) {
            containerViewHeight.constant = scrollView.contentInset.top
            let offsetY = -(scrollView.contentOffset.y + scrollView.contentInset.top)
            containerView.clipsToBounds = offsetY <= 0
            imageViewBottom.constant = offsetY >= 0 ? 0 : -offsetY / 2
            imageViewHeight.constant = max(offsetY + scrollView.contentInset.top, scrollView.contentInset.top)
        }
        
    }
    

    public func scrollviewDidScroll(scrollView: UIScrollView) is the function that you need to call in your main ViewController's scrollViewDidScroll method.

    Register above class to your collectioView:

    collectionView.register(StretchyCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: StretchyCollectionHeaderView.reuseIdentifier)
    

    Add this to your section header while creating layout for collectionView:

     func generateFirstSectionLayout(isWide: Bool) -> NSCollectionLayoutSection {
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .absolute(44))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)
    
            // Set header properties here
            let headerSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalWidth(isWide ? 2/4 : 2/3))
            let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: headerSize,
                elementKind: HomePageViewController.sectionHeaderElementKind,
                alignment: .top)
    
            let section = NSCollectionLayoutSection(group: group)
            section.boundarySupplementaryItems = [sectionHeader]
    
            return section
        }
    

    Add data to your header:

     func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
          if indexPath.section == 0 {
                guard let supplementaryView = collectionView.dequeueReusableSupplementaryView(
                    ofKind: kind,
                    withReuseIdentifier: StretchyCollectionHeaderView.reuseIdentifier,
                    for: indexPath) as? StretchyCollectionHeaderView else { fatalError("Cannot create header view") }
    
                supplementaryView.imageView.image = UIImage(named: "HeaderHomePage")
                return supplementaryView
            }
            else {
                guard let supplementaryView = collectionView.dequeueReusableSupplementaryView(
                    ofKind: kind,
                    withReuseIdentifier: HomePageAutoxHeaderView.reuseIdentifier,
                    for: indexPath) as? HeaderView else { fatalError("Cannot create header view") }
    
                return supplementaryView
            }
        }
    

    Add to first section and then finally call this in your ViewController:

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if let header = homePageCollectionView.supplementaryView(forElementKind: HomePageViewController.sectionHeaderElementKind, at: IndexPath(item: 0, section: 0)) as? StretchyCollectionHeaderView {
                    header.scrollviewDidScroll(scrollView: homePageCollectionView)
         }
    }
    
    

    In case your header only stretches after you start scrolling, call above function in viewDidAppear() too.