iosswiftuicollectionviewheaderuicollectionreusableview

Delegate callback for UICollectionReusableView subclass in Swift?


I have a custom header/footer class

class CustomCollectionReusableView: UICollectionReusableView {
   var pageControl: UIPageControl!
   ...
}

and use it in UICollectionViewDataSource appropriate method:

func collectionView(
        _ collectionView: UICollectionView,
        viewForSupplementaryElementOfKind kind: String,
        at indexPath: IndexPath
    ) -> UICollectionReusableView {
    if kind == customKind {
        ...//return CustomCollectionReusableView object
    }
    ...
}

The problem is if I have multiple sections with the same this header/footer then how should I get callback to know in which of them user clicked on pageControl (get indexPath)?

If it was UITableViewCell/UICollectionViewCell subclass I could just call a custom delegate in it with a cell-sender param and then call for example collectionView.indexPath(for:).

The problem is there is no such method for headers/footers. There is collectionView.indexPathsForVisibleSupplementaryElements(ofKind:) only which returns multiple indexPath objects.

How to resolve this issue correctly? Should I set callback block or indexPath inside this view?


Solution

  • You can use protocol/delegate pattern, but Swift likes closures.

    Depending on what all you need to do, you may want to take different approaches.

    IF you know your collection view data structure will not change, you can capture the section in a closure.

    For example:

    class InteractiveHeaderView: UICollectionReusableView {
        
        public var myValueChanged: ((Int) -> ())?
    
        @objc func pgChanged(_ sender: UIPageControl) {
            myValueChanged?(sender.currentPage)
        }
    
        // all the rest of your header (supplementary) view setup
    
    }
    

    Then, your viewForSupplementaryElementOfKind might look something like this:

    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        
        switch kind {
        case UICollectionView.elementKindSectionHeader:
            let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: InteractiveHeaderView.identifier, for: indexPath) as! InteractiveHeaderView
            
            let thisSection: Int = indexPath.section
            
            // set properties in headerView
    
            // set the closure
            headerView.myValueChanged = { [weak self] val in
                guard let self = self else { return }
                
                // do something based on the val passed by the section's header view
                self.updateValueForSection(section: thisSection, val: val)
            }
            
            return headerView
            
        default:
            assert(false, "Invalid element type")
        }
        
    }
    

    On the other hand, suppose you have 30 sections, and based on user-interaction or data events, you might be deleting / inserting / re-ordering sections, you don't want to rely on the captured section.

    In that case, we can pass back the header view instance, and loop through the visible supplementary views to find the index path.

    So our closure changes to:

    class InteractiveHeaderView: UICollectionReusableView {
    
        public var myValueChanged: ((UICollectionReusableView, Int) -> ())?
    
        @objc func pgChanged(_ sender: UIPageControl) {
            myValueChanged?(self, sender.currentPage)
        }
    
        // all the rest of your header (supplementary) view setup
    
    }
    

    and we can do this when setting the closure:

    headerView.myValueChanged = { [weak self] thisView, val in
        guard let self = self else { return }
    
        // user interacted with a section header view, so
        //  the header view executing this closure must be visible...
    
        // get the indexPaths of the visible supplementary views
        let idxPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: kind)
    
        for pth in idxPaths {
            if let thisHeaderView = self.collectionView.supplementaryView(forElementKind: kind, at: pth), thisHeaderView == thisView {
                // do something based on the val passed by the section's header view
                self.updateValueForSection(section: pth.section, val: val)
            }
        }
    }
    

    Here's a complete example..

    We'll start by defining a "section" data struct:

    struct MySection {
        var myVal: Int = 0
        var numItems: Int = 0
    }
    

    Where myVal will be an index into an array of colors, which we'll use for the background of the label in a collection view cell, and numItems will be the number of items in the section.

    It will look like this when running:

    enter image description here

    Changing the current page in the section header will change the background color of the labels in that section:

    enter image description here


    SimpleCell: UICollectionViewCell class - with a single, centered label...

    class SimpleCell: UICollectionViewCell {
        
        static let identifier: String = "simpleCell"
        
        public var bkgColor: UIColor = .systemRed { didSet { theLabel.backgroundColor = bkgColor } }
        
        public var theLabel: UILabel = {
            let label = UILabel()
            label.font = .systemFont(ofSize: 12, weight: .bold)
            label.font = .monospacedSystemFont(ofSize: 12, weight: .bold)
            label.textColor = .white
            label.textAlignment = .center
            return label
        }()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        
        private func commonInit() {
            
            theLabel.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(theLabel)
            
            let g = contentView.layoutMarginsGuide
            NSLayoutConstraint.activate([
                theLabel.topAnchor.constraint(equalTo: g.topAnchor),
                theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor),
            ])
            
            theLabel.backgroundColor = bkgColor
            
            // so we can see the cell framing
            contentView.backgroundColor = .yellow
            contentView.layer.borderColor = UIColor.black.cgColor
            contentView.layer.borderWidth = 1
            
        }
    }
    

    InteractiveHeaderView: UICollectionReusableView class - with a label and a page control...

    class InteractiveHeaderView: UICollectionReusableView {
        
        static let identifier: String = "interactiveHeaderView"
        
        public var myValueChanged: ((UICollectionReusableView, Int) -> ())?
        
        public var title: String = "" { didSet { titleLabel.text = title } }
        public var numPages: Int = 1 { didSet { pageControl.numberOfPages = numPages } }
        public var curPage: Int = 0 { didSet { pageControl.currentPage = curPage } }
        
        private let titleLabel: UILabel = UILabel()
        private let pageControl: UIPageControl = UIPageControl()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            [titleLabel, pageControl].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                addSubview(v)
            }
            NSLayoutConstraint.activate([
                
                titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
                titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
                titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
                
                pageControl.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
                pageControl.centerYAnchor.constraint(equalTo: centerYAnchor),
                
            ])
            
            backgroundColor = UIColor(red: 0.0, green: 0.2, blue: 0.5, alpha: 1.0)
            titleLabel.textColor = .white
            
            pageControl.backgroundStyle = .prominent
            pageControl.numberOfPages = numPages
            
            pageControl.addTarget(self, action: #selector(pgChanged(_:)), for: .valueChanged)
            
        }
        
        @objc func pgChanged(_ sender: UIPageControl) {
            myValueChanged?(self, sender.currentPage)
        }
        
    }
    

    InteractiveHeaderViewController: UIViewController class - to demonstrate...

    class InteractiveHeaderViewController: UIViewController {
        
        var collectionView: UICollectionView!
        
        var myData: [MySection] = []
        
        let sectionColors: [UIColor] = [
            .systemRed, .systemGreen, .systemBlue,
            .systemBrown, .systemCyan
        ]
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // some sample data
            let iCounts: [Int] = [
                7, 16, 9, 5, 8, 12, 13, 10, 3, 7, 11
            ]
            iCounts.forEach { n in
                // we'll start the data with all sections using color Zero
                let s = MySection(myVal: 0, numItems: n)
                myData.append(s)
            }
            
            let fl = UICollectionViewFlowLayout()
            fl.scrollDirection = .vertical
            fl.minimumLineSpacing = 4.0
            fl.minimumInteritemSpacing = 4.0
            fl.itemSize = .init(width: 54.0, height: 36.0)
            fl.sectionInset = .init(top: 4.0, left: 4.0, bottom: 4.0, right: 4.0)
            fl.headerReferenceSize = .init(width: 200.0, height: 60.0)
            
            collectionView = UICollectionView(frame: .zero, collectionViewLayout: fl)
            
            collectionView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(collectionView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 2.0),
                collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
            ])
            
            collectionView.register(SimpleCell.self, forCellWithReuseIdentifier: SimpleCell.identifier)
            collectionView.register(InteractiveHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: InteractiveHeaderView.identifier)
            
            collectionView.dataSource = self
            collectionView.delegate = self
            
            collectionView.backgroundColor = .systemYellow
        }
        
        func updateValueForSection(section: Int, val: Int) {
            
            myData[section].myVal = val
            
            for i in 0..<collectionView.numberOfItems(inSection: section) {
                if let c = collectionView.cellForItem(at: IndexPath(item: i, section: section)) as? SimpleCell {
                    c.bkgColor = sectionColors[myData[section].myVal]
                }
            }
            
        }
        
    }
    
    extension InteractiveHeaderViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UICollectionViewDelegate {
        
        func numberOfSections(in collectionView: UICollectionView) -> Int {
            return myData.count
        }
        
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return myData[section].numItems
        }
        
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SimpleCell.identifier, for: indexPath) as! SimpleCell
            cell.theLabel.text = "\(indexPath.section),\(indexPath.item)"
            cell.bkgColor = sectionColors[myData[indexPath.section].myVal]
            return cell
        }
        
        func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
            
            switch kind {
            case UICollectionView.elementKindSectionHeader:
                let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: InteractiveHeaderView.identifier, for: indexPath) as! InteractiveHeaderView
                
                let thisSection: Int = indexPath.section
                headerView.title = "Sec: \(thisSection)"
                headerView.numPages = sectionColors.count
                headerView.curPage = myData[thisSection].myVal
                
                headerView.myValueChanged = { [weak self] thisView, val in
                    guard let self = self else { return }
                    
                    // user interacted with a section header view, so
                    //  the header view executing this closure must be visible...
                    
                    // get the indexPaths of the visible supplementary views
                    let idxPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: kind)
                    
                    for pth in idxPaths {
                        if let thisHeaderView = self.collectionView.supplementaryView(forElementKind: kind, at: pth), thisHeaderView == thisView {
                            // do something based on the val passed by the section's header view
                            self.updateValueForSection(section: pth.section, val: val)
                        }
                    }
                }
                
                return headerView
            default:
                assert(false, "Invalid element type")
            }
            
        }
        
    }