Custom Collection View layout: 'Invalid parameter not satisfying: self.supplementaryViewProvider'

682 views Asked by At

I am using two collectionViews in my view controller. 1 has a custom layout and custom attributes for its supplementary views.

Here is the view controller:

class ProgressViewController: UIViewController {

    private lazy var data = fetchData()
    
    private lazy var recordsDataSource = makeRecordsDataSource()
    private lazy var timelineDataSource = makeTimelineDataSource()
    
    fileprivate typealias RecordsDataSource = UICollectionViewDiffableDataSource<YearMonthDay, TestRecord>
    fileprivate typealias RecordsDataSourceSnapshot = NSDiffableDataSourceSnapshot<YearMonthDay, TestRecord>
    
    fileprivate typealias TimelineDataSource = UICollectionViewDiffableDataSource<TimelineSection,YearMonthDay>
    fileprivate typealias TimelineDataSourceSnapshot = NSDiffableDataSourceSnapshot<TimelineSection,YearMonthDay>

    private var timelineMap = TimelineMap()
    private var curItemIndex = 0
    private var curRecordsOffset: CGFloat = 0
    private var curTimelineOffset: CGFloat = 0
    private var timelineStart: Date?

    @IBOutlet var recordsCollectionView: UICollectionView!
    @IBOutlet var timelineCollectionView: UICollectionView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        data = fetchData()
        timelineStart = Array(data.keys).first?.date
        configureRecordsDataSource()
        configureTimelineDataSource()
        configureTimelineSupplementaryViews()
        applyRecordsSnapshot()
        applyTimelineSnapshot()
       
        if let collectionViewLayout = timelineCollectionView.collectionViewLayout as? TimelineLayout {
            collectionViewLayout.delegate = self
        }
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        var i: CGFloat = 0
        for _ in data {
            let recordOffset = self.recordsCollectionView.frame.width * i
            let timelineOffset = (timelineCollectionView.frame.width + CGFloat(10)) * i
            timelineMap.set(recordOffset: recordOffset, timelineOffset: timelineOffset)
            i += 1
        }
    }
    
    private func fetchData() -> [YearMonthDay:[TestRecord]] {
        var data: [YearMonthDay:[
            TestRecord]] = [:]
        var testRecords:[TestRecord] = []
        for i in 0...6{
            let testRecord = TestRecord(daysBack: i*2, progression: 0)
            testRecords.append(testRecord)
        }
        for record in testRecords {
            let ymd = YearMonthDay(date:record.timeStamp,records: [])
            if var day = data[ymd] {
                day.append(record)
            } else {
                data[ymd] = [record]
            }
        }
        return data
    }

extension ProgressViewController {
    //RECORDS data
    fileprivate func makeRecordsDataSource() -> RecordsDataSource {
        let dataSource = RecordsDataSource(
            collectionView: recordsCollectionView,
            cellProvider: { (collectionView, indexPath, testRecord) ->
              UICollectionViewCell? in
                let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RecordCollectionViewCell.identifier, for: indexPath) as? RecordCollectionViewCell
                cell?.configure(with: testRecord)
                return cell
            })
        return dataSource
    }
    func configureRecordsDataSource() {
        self.recordsCollectionView.register(RecordCollectionViewCell.nib, forCellWithReuseIdentifier: RecordCollectionViewCell.identifier)
    }
    func applyRecordsSnapshot() {
      // 2
        var snapshot = RecordsDataSourceSnapshot()
        for (ymd,records) in data {
            snapshot.appendSections([ymd])
            snapshot.appendItems(records,toSection: ymd)
        }
        recordsDataSource.apply(snapshot, animatingDifferences: false)
    }
}

extension ProgressViewController {
    enum TimelineSection {
        case main
    }
    fileprivate func makeTimelineDataSource() -> TimelineDataSource {
        let dataSource = TimelineDataSource(
            collectionView: self.timelineCollectionView,
            cellProvider: { (collectionView, indexPath, testRecord) ->
              UICollectionViewCell? in
                let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TimelineDayCell.identifier, for: indexPath) as? TimelineDayCell
                cell?.backgroundColor = .orange
                cell?.dayLabel.text = String(indexPath.section)+","+String(indexPath.row)
                return cell
            })
        return dataSource
    }
    func configureTimelineDataSource() {
        self.timelineCollectionView!.register(TimelineDayCell.nib, forCellWithReuseIdentifier: TimelineDayCell.identifier)
        timelineCollectionView.register(
           UINib(nibName: "MonthHeader", bundle: nil),
           forSupplementaryViewOfKind: TimelineLayout.Element.month.kind,
           withReuseIdentifier: TimelineLayout.Element.month.id)
        timelineCollectionView.register(
           UINib(nibName: "TimelineDayCell", bundle: nil),
           forSupplementaryViewOfKind: TimelineLayout.Element.day.kind,
           withReuseIdentifier: TimelineLayout.Element.day.id)
        timelineCollectionView.register(
           UINib(nibName: "YearLabel", bundle: nil),
           forSupplementaryViewOfKind: TimelineLayout.Element.year.kind,
           withReuseIdentifier: TimelineLayout.Element.year.id)
    }
    func applyTimelineSnapshot(animatingDifferences: Bool = false) {
        // 2
        var snapshot = TimelineDataSourceSnapshot()
        snapshot.appendSections([.main])
        snapshot.appendItems(Array(data.keys))
        timelineDataSource.apply(snapshot, animatingDifferences: animatingDifferences)
    }
    
    func configureTimelineSupplementaryViews(){
        timelineDataSource.supplementaryViewProvider = { (
                    collectionView: UICollectionView,
                    kind: String,
                    indexPath: IndexPath) -> UICollectionReusableView? in
            switch kind {
            case TimelineLayout.Element.month.kind:
                let supplementaryView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: TimelineLayout.Element.month.id, for: indexPath)
                if let monthLabelView = supplementaryView as? MonthHeader {
                    let active = Calendar.current.date(byAdding: .month, value: indexPath.item, to: self.timelineStart!)
                    let dateFormatter = DateFormatter()
                    dateFormatter.dateFormat = "MMM"
                    let monthString = dateFormatter.string(from: active!)
                    monthLabelView.monthLabel.text = monthString
                }
                return supplementaryView
            case TimelineLayout.Element.year.kind:
                let supplementaryView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: TimelineLayout.Element.year.id, for: indexPath)
                if let yearLabelView = supplementaryView as? YearLabel {
                    let labelDate = Calendar.current.date(byAdding: .year, value: indexPath.item, to: self.timelineStart!)!
                    let year = Calendar.current.component(.year, from: labelDate)
                    yearLabelView.yearLabel.text = String(year)
                }
                return supplementaryView
            default:
                fatalError("This should never happen!!")
            }
        }
    }
}

extension ProgressViewController: TimelineLayoutDelegate {
    func collectionView(_ collectionView: UICollectionView, dateAtIndexPath indexPath: IndexPath) -> Date? {
        return timelineDataSource.itemIdentifier(for: indexPath)?.date
    }
}

Then in my custom TimelineLayout I have

class TimelineLayout: UICollectionViewLayout {
    
    weak var delegate: TimelineLayoutDelegate!
    
    enum Element: String {
        case day
        case month
        case year
        
        var id: String {
            return self.rawValue
        }
    
        var kind: String {
            return "Kind\(self.rawValue.capitalized)"
        }
    }
    private var oldBounds = CGRect.zero
    private var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()
    private var contentWidth = CGFloat()
    private var cache = [Element: [IndexPath: UICollectionViewLayoutAttributes]]()
    private var monthHeight: CGFloat = 19
    private var yearHeight: CGFloat = 11
    private var dayHeight: CGFloat = 30
    private var cellWidth: CGFloat = 2.5
    private var collectionViewStartY: CGFloat {
        guard let collectionView = collectionView else {
            return 0
        }
        return collectionView.bounds.minY
    }
    private var collectionViewHeight: CGFloat {
        return collectionView!.frame.height
    }
    override public var collectionViewContentSize: CGSize {
        return CGSize(width: contentWidth, height: collectionViewHeight)
    }
}

extension TimelineLayout {
    override public func prepare() {
        guard let collectionView = collectionView,
            cache.isEmpty else {
                return
        }
        collectionView.decelerationRate = .fast
        updateInsets()
        cache.removeAll(keepingCapacity: true)
        cache[.year] = [IndexPath: UICollectionViewLayoutAttributes]()
        cache[.month] = [IndexPath: UICollectionViewLayoutAttributes]()
        cache[.day] = [IndexPath: UICollectionViewLayoutAttributes]()
        oldBounds = collectionView.bounds
        var timelineStart: Date?
        var timelineEnd: Date?

        for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
            let cellIndexPath = IndexPath(item: item, section: 0)
            guard let cellDate = delegate.collectionView(collectionView, dateAtIndexPath: cellIndexPath) else {
                return
            }
            if item == 0 {
                let firstDate = cellDate
                timelineStart = Calendar.current.startOfDay(for: firstDate)
            }
            if item == collectionView.numberOfItems(inSection: 0) - 1 {
                timelineEnd = cellDate
            }
            
            let startX = CGFloat(cellDate.days(from: timelineStart!)) * cellWidth
            let dayCellattributes = UICollectionViewLayoutAttributes(forCellWith: cellIndexPath)
            dayCellattributes.frame = CGRect(x: startX, y: collectionViewStartY + yearHeight + monthHeight, width: cellWidth, height: dayHeight)
            cache[.day]?[cellIndexPath] = dayCellattributes
            contentWidth = max(startX + cellWidth,contentWidth)
        }
        ///TODO - what if there are no items in the section....
        
        guard let monthStart = timelineStart?.startOfMonth(), let monthEnd = timelineEnd?.endOfMonth() else { return }
        let begin = Calendar.current.date(byAdding: .month, value: -4, to: monthStart)
        let end = Calendar.current.date(byAdding: .month, value: 4, to: monthEnd)
        var date: Date = begin!
        
        let initalOffset = CGFloat((timelineStart?.days(from: date))!) * cellWidth
        
        var monthOffset: CGFloat = 0
        var monthIndex: Int = 0
        var yearIndex: Int = 0
        
        while date <= end! {
            
            let daysInMonth = Calendar.current.range(of: .day, in: .month, for: date)?.count
            let monthWidth = cellWidth * CGFloat(daysInMonth!)
            //let monthIndex = date.months(from: timelineStart!)
            
            let monthLabelIndexPath = IndexPath(item: monthIndex, section: 0)
            let monthLabelAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: Element.month.kind, with: monthLabelIndexPath)
            let startX = monthOffset - initalOffset
            monthLabelAttributes.frame = CGRect(x: startX, y: collectionViewStartY + yearHeight, width: monthWidth, height: monthHeight)
            print(startX,"spaghet")
            cache[.month]?[monthLabelIndexPath] = monthLabelAttributes
            monthOffset += monthWidth
            
            if Calendar.current.component(.month, from: date) == 1 || yearIndex == 0 {
                //draw year
                //let year = Calendar.current.component(.year, from: date)
                //let yearIndex = date.years(from: timelineStart!)
                let daysFromStartOfYear = date.days(from: date.startOfYear())
                let startYearX = startX - CGFloat(daysFromStartOfYear) * cellWidth
                let yearLabelIndexPath = IndexPath(item: yearIndex, section: 0)
                let yearLabelAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: Element.year.kind, with: yearLabelIndexPath)
                yearLabelAttributes.frame = CGRect(x: startYearX, y: collectionViewStartY, width: CGFloat(30), height: yearHeight)
                cache[.year]?[yearLabelIndexPath] = yearLabelAttributes
                yearIndex += 1
            }
            date = Calendar.current.date(byAdding: .month, value: 1, to: date)!
            monthIndex += 1
        }
    }
}


extension TimelineLayout {
    public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        switch elementKind {
        case Element.year.kind:
            return cache[.year]?[indexPath]
        default:
            return cache[.month]?[indexPath]
        }

    }

        override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        visibleLayoutAttributes.removeAll(keepingCapacity: true)
        if let yearAttrs = cache[.year], let monthAttrs = cache[.month], let dayAttrs = cache[.day] {
            for (_, attributes) in yearAttrs {
                if attributes.frame.intersects(rect) {
                    visibleLayoutAttributes.append(attributes)
                }
            }
            for (_, attributes) in monthAttrs {
                if attributes.frame.intersects(rect) {
                    visibleLayoutAttributes.append(attributes)
                }
            }
            for (_, attributes) in dayAttrs {
                if attributes.frame.intersects(rect) {
                    visibleLayoutAttributes.append(attributes)
                }
//                visibleLayoutAttributes.append(self.shiftedAttributes(from: attributes))
            }
        }
        return visibleLayoutAttributes
    }
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let attributes = cache[.day]?[indexPath] else { fatalError("No attributes cached") }
        return attributes
//        return shiftedAttributes(from: attributes)
    }

    override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        if oldBounds.size != newBounds.size {
            cache.removeAll(keepingCapacity: true)
        }
        return true
    }
    
    override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
        if context.invalidateDataSourceCounts { cache.removeAll(keepingCapacity: true) }
        super.invalidateLayout(with: context)
    }
}

As you can see I do create timelineDataSource supplementary View Provider in the view controller. Then in the Timeline Layout I implement layoutAttributesForElements(in rect: CGRect) and layoutAttributesForSupplementaryView(ofKind .... The latter never gets called - the error comes first. layoutAttributesForElements(in rect: CGRect) does get called and filled with dayAttrs, monthAttrs, and yearAttrs. However still, the error occurs afterwards:

libc++abi.dylib: terminating with uncaught exception of type NSException *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid parameter not satisfying: self.supplementaryViewProvider || (self.supplementaryReuseIdentifierProvider && self.supplementaryViewConfigurationHandler)' terminating with uncaught exception of type NSException

What am I missing?

Edit: I want to add the detail that when I put a breakpoint in that supplementaryViewProvider it's never called. So maybe I am doing something wrong in layoutAttributesForElements(in Rect?)

0

There are 0 answers