Lazy Loading UICompositionalLayout

79 Views Asked by At

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.

enter image description here

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
    }
}
1

There are 1 best solutions below

6
DonMag On

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:

collectionView.backgroundColor = .clear

// and

cell.backgroundColor = .red.withAlphaComponent(0.7)

use:

collectionView.backgroundColor = .red.withAlphaComponent(0.7)

// and

cell.backgroundColor = .clear

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: false to true ... 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 UICollectionView does 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:

  • layout the cell frames
  • if a frame is visible
    • request the cell content

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:

enter image description here

The yellow rects show the visible part of the collection view...

  • A - Section Background view
  • B - Cell (gray is just to show the frame... it will be clear)
  • C - Section has one Cell, in "Loading..." state. Full width.
  • D - Section has multiple cells, and the frame for Cell 0 has been set
  • E - No visual change, because the cell is clear and has no "right-side" border
  • F - Cells have been requested
  • G - UI has been updated

As a side note... Compositional Layout is really designed for multiple "disparate layout" sections. A custom UICollectionViewLayout may be much better suited for this type of "grid" layout.