I'm trying to lazy load sections in a UICollectionView to create a TVGuide. I'm using UICompositionalLayout as well as UIDiffableDataSource to take advantage of these new APIs plus reduce performance overhead.
I have everything working but as soon as the sections are about to reload, you see that there is a redraw issue with the Loading... cell. It's sized up to the first Event but it should disappear seamlessly as the events come in.
I have an image below which better shows the problem, plus the code snippet below which you can simply copy and paste into a blank project to test. Been trying to figure this out for weeks but just can't figure out what to do. Any help is greatly appreciated.
import UIKit
class ViewController: UIViewController, UIScrollViewDelegate {
private var collectionView: UICollectionView!
typealias DataSource = UICollectionViewDiffableDataSource<String, String>
typealias DataSourceSnapshot = NSDiffableDataSourceSnapshot<String, String>
private var dataSource: DataSource!
private var sections: [SectionViewModel] = []
private var cellWidths: [Int: [CGFloat]] = [:]
private let operationQueue = OperationQueue()
private let scheduleWidth: CGFloat = 1500
init() {
super.init(nibName: nil, bundle: nil)
sections = (0...150).enumerated().map({ index, object in
let event = EventCellViewModel(name: "Loading...Section-\(index)", width: scheduleWidth)
let sectionViewModel = SectionViewModel(events: [event])
return sectionViewModel
})
view.backgroundColor = .blue
collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
collectionView.delegate = self
collectionView.dataSource = dataSource
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
collectionView.bounces = false
collectionView.register(EventCell.self, forCellWithReuseIdentifier: "EventCell")
collectionView.isOpaque = true
operationQueue.qualityOfService = .utility
operationQueue.maxConcurrentOperationCount = 1
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
configureDataSource()
createDumbyData()
}
private func configureDataSource() {
dataSource = DataSource(collectionView: collectionView, cellProvider: { [weak self] collectionView, indexPath, object in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "EventCell", for: indexPath) as! EventCell
let viewModel = self?.sections[indexPath.section].events[indexPath.row]
cell.programmeTitle.text = viewModel?.name
cell.isOpaque = true
cell.backgroundColor = .red.withAlphaComponent(0.7)
return cell
})
}
private func applySnapshot() {
operationQueue.addOperation { [weak self] in
guard let self = self else { return }
var snapshot = DataSourceSnapshot()
for (sectionIndex, section) in self.sections.enumerated() {
let sectionId = "Section-\(sectionIndex)"
snapshot.appendSections([sectionId])
let eventIds = section.events.enumerated().map { "Event-\(sectionIndex)-\($0)" }
snapshot.appendItems(eventIds, toSection: sectionId)
}
self.dataSource.apply(snapshot, animatingDifferences: false)
}
}
private func updateSnapshot(sectionIndex: Int) {
guard sections[sectionIndex].isLoading else { return }
let newEvents = (0..<20).map {
EventCellViewModel(name: "Event-\(sectionIndex)-\($0)")
}
if cellWidths[sectionIndex] == nil {
cellWidths[sectionIndex] = newEvents.map { $0.width }
}
self.sections[sectionIndex].events = newEvents
self.sections[sectionIndex].isLoading = false
applySnapshot()
}
private func createDumbyData() {
let sections = (0...150).map { index in
SectionViewModel(events: [EventCellViewModel(name: "Loading...\(index)", width: 1500)])
}
self.sections = sections
applySnapshot()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func createLayout() -> UICollectionViewLayout {
let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) in
let items = self.layoutItems(for: sectionIndex)
let scheduleWidth = self.scheduleWidth(for: sectionIndex)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(scheduleWidth),
heightDimension: .absolute(60))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: items)
// Create the section
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .none
section.contentInsets.bottom = 0
return section
}
return layout
}
private func layoutItems(for sectionIndex: Int) -> [NSCollectionLayoutItem] {
return cellWidths(for: sectionIndex).map { width in
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(60))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
return item
}
}
private func scheduleWidth(for sectionIndex: Int) -> CGFloat {
sections[sectionIndex].events.map { $0.width }.reduce(0, +)
}
func cellWidths(for sectionIndex: Int) -> [CGFloat] {
cellWidths[sectionIndex] ?? [1500]
}
func isLoading(for section: Int) -> Bool {
sections[section].isLoading
}
private func numberOfEvents(for index: Int) -> Int {
sections[index].events.count
}
private func viewModel(for sectionIndex: Int, at itemIndex: Int) -> EventCellViewModel? {
sections[sectionIndex].events[itemIndex]
}
}
// MARK: - UICollectionViewDelegate
extension ViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
reloadCell(at: indexPath.section)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return sections[section].events.count
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return sections.count
}
func reloadCell(at index: Int) {
self.updateSnapshot(sectionIndex: index)
}
}
// MARK: - ViewModels
public struct SectionViewModel: Hashable {
var events: [EventCellViewModel]
var isLoading = true
}
public struct EventCellViewModel {
var uuid: String { return name }
public let name: String
public let width: CGFloat
init(name: String, width: CGFloat = CGFloat(Int.random(in: 100...400))) {
self.name = name
self.width = width
}
}
extension EventCellViewModel: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(uuid)
}
public static func ==(lhs: EventCellViewModel, rhs: EventCellViewModel) -> Bool {
return lhs.uuid == rhs.uuid
}
}
// MARK: - Cell
class EventCell: UICollectionViewCell {
let programmeTitle = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
programmeTitle.textColor = .white
programmeTitle.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(programmeTitle)
contentView.layer.borderColor = UIColor.blue.withAlphaComponent(0.4).cgColor
contentView.layer.borderWidth = 1
NSLayoutConstraint.activate([
programmeTitle.topAnchor.constraint(equalTo: contentView.topAnchor),
programmeTitle.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
programmeTitle.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
programmeTitle.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(title: String?) {
programmeTitle.text = title
}
}

It appears to be a combination of how you're doing the layout, and "layout timing" ... but a little tough to say without being able to evaluate the code involved with making the HTTP calls.
What you could try...
Instead of:
use:
That may be sufficient... give it a try.
Perhaps worth noting: with your current approach, if you have scrolled horizontally, the "Loading..." text will not be visible - it will be "off the left side of the screen."
Edit
Try changing
animatingDifferences: falsetotrue... see if that is acceptable.If not, another approach could be to configure your cell to have the "Loading..." label always there, and show/hide it based on the state -- instead of changing its text and frame to the first "event."
Edit 2 - couple more thoughts...
I think it's going to be very difficult to solve this with your current approach.
As you're seeing, a
UICollectionViewdoes not request the cell until the cell needs to be displayed.When you have a section with a single, 1500-point width cell ("Loading..."), and you update the section with multiple, narrower cells - let's say cell 0 has a width of 200 - the collection view says:
So, since cell 0 is already visible, its frame width changes to 200 and then cell 1 is requested.
Because your cell 0 view has a border, we see the border change first.
You could try using a section background view and clear cells with only a "left-edge" border. Something like this:
The yellow rects show the visible part of the collection view...
As a side note... Compositional Layout is really designed for multiple "disparate layout" sections. A custom
UICollectionViewLayoutmay be much better suited for this type of "grid" layout.