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?)