ios – Lazy Loading UICompositionalLayout


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

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img