pbuxaroff
pbuxaroff

Reputation: 41

UICollectionView weird layout

I'm stuck with layout issue while displaying collection view cells. All ui is done programmatically. Here's what happening while scrolling down (ui elements appearing from top left corner):

enter image description here

Content of cell is being laid out on the fly, can't fix this. Content should be laid out before appearing. Any suggestions?

Code:

class CollectionView: UICollectionView {
    
    // MARK: - Enums
    enum Section {
        case main
    }
    
    typealias Source = UICollectionViewDiffableDataSource<Section, Item>
    typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
    
    private var source: Source!
    private var dataItems: [Item] {...}
    
    init(items: [Item]) {
        super.init(frame: .zero, collectionViewLayout: .init())
        
        collectionViewLayout = UICollectionViewCompositionalLayout { section, env -> NSCollectionLayoutSection? in
            return NSCollectionLayoutSection.list(using: UICollectionLayoutListConfiguration(appearance: .plain), layoutEnvironment: env)
        
        let cellRegistration = UICollectionView.CellRegistration<Cell, Item> { [unowned self] cell, indexPath, item in
            cell.item = item
            ...modifications..
        }

        source = UICollectionViewDiffableDataSource<Section, Item>(collectionView: self) {
            collectionView, indexPath, identifier -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
                                                                    for: indexPath,
                                                                    item: identifier)
        }
    }

    func setDataSource(animatingDifferences: Bool = true) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(dataItems, toSection: .main)
        source.apply(snapshot, animatingDifferences: animatingDifferences)
    }
    
}

//Cell


class Cell: UICollectionViewListCell {
    public weak var item: Item! {
        didSet {
            guard !item.isNil else { return }
            
            updateUI()
        }
    }

    private lazy var headerView: UIStackView = {...nested UI setup...}()
    private lazy var middleView: UIStackView = {...nested UI setup...}()
    private lazy var bottomView: UIStackView = {...nested UI setup...}()

    override init(frame: CGRect) {
        super.init(frame: frame)
        
        setupUI()
    }


    private func setupUI() {
        let items = [
            headerView,
            middleView,
            bottomView
        ]
        items.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
        contentView.addSubviews(items)
        contentView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            contentView.topAnchor.constraint(equalTo: topAnchor),
            contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
            headerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
            headerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
            headerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
            middleView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: padding),
            middleView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
            middleView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
            bottomView.topAnchor.constraint(equalTo: middleView.bottomAnchor, constant: padding),
            bottomView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
            bottomView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
        ])
        
        let constraint = bottomView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding)
        constraint.priority = .defaultLow
        constraint.isActive = true
    }

    
    @MainActor
    func updateUI() {
        func updateHeader() {
            //...colors & other ui...
        }
        
        func updateMiddle() {
            middleView.addArrangedSubview(titleLabel)
            middleView.addArrangedSubview(descriptionLabel)
            
            guard let constraint = titleLabel.getConstraint(identifier: "height"),
                  let constraint2 = descriptionLabel.getConstraint(identifier: "height")
            else { return }
            
            titleLabel.text = item.title
            descriptionLabel.text = item.truncatedDescription
            
//Tried to force layout - didn't help
//            middleView.setNeedsLayout()
//calc ptx height
            constraint.constant = item.title.height(withConstrainedWidth: bounds.width, font: titleLabel.font)
            //Media
            if let media = item.media {
                middleView.addArrangedSubview(imageContainer)
                
                if let image = media.image {
                    imageView.image = image
                } else if !media.imageURL.isNil {
                    guard let shimmer = imageContainer.getSubview(type: Shimmer.self) else { return }
                    
                    shimmer.startShimmering()
                    Task { [weak self] in
                        guard let self = self else { return }

                        try await media.downloadImageAsync()

                        media.image.publisher
                            .sink {
                                self.imageView.image = $0
                                shimmer.stopShimmering()
                            }
                            .store(in: &self.subscriptions)
                    }
                }
            }
            constraint2.constant = item.truncatedDescription.height(withConstrainedWidth: bounds.width, font: descriptionLabel.font)
//            middleView.layoutIfNeeded()
        }
        
        func updateBottom() { //...colors & other ui... }
        

        updateHeader()
        updateMiddle()
        updateBottom()
    }

    
    override func prepareForReuse() {
        super.prepareForReuse()
        
        //UI cleanup
        middleView.removeArrangedSubview(titleLabel)
        middleView.removeArrangedSubview(descriptionLabel)
        middleView.removeArrangedSubview(imageContainer)
        titleLabel.removeFromSuperview()
        descriptionLabel.removeFromSuperview()
        imageContainer.removeFromSuperview()
        imageView.image = nil
    }
}

Tried to force layout in UICollectionViewDelegate, it didn't help:

    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        cell.setNeedsLayout()
        cell.layoutIfNeeded()
    }

Why is layout engine behaving so strange and how to fix it?

Upvotes: 0

Views: 187

Answers (1)

SimeonRumy
SimeonRumy

Reputation: 403

Hard to debug layout issues. But I don't believe you need to remove your views, like so:

override func prepareForReuse() {
    super.prepareForReuse()
    middleView.removeArrangedSubview(titleLabel)
    middleView.removeArrangedSubview(descriptionLabel)
    middleView.removeArrangedSubview(imageContainer)
    titleLabel.removeFromSuperview()
    descriptionLabel.removeFromSuperview()
    imageContainer.removeFromSuperview()

That forced unnecessary layout steps, which might explain why your cell re-layout every time.

Instead just set the field's value to nill. Like you did with the image. When you call your function to updateUI() add the new values from that new item. You should not be updating your constraints here. Your contents' intrinsic content size will change and your cell should be able adapt if the constraints are defined correctly in the initial setupUI() method.

Auto layout constraints are linear equations, so when a variable changes they should be able to adapt once the layout engine recalculates the values.

It may take some time to get it right. You might have to play around with compression resistance and content hugging priorities to ensure the test field doesn't shrink, A good rule of thumb is to try and simplify your constraints as much as possible. Sorry cant be more specific as its hard to debug layout without a simulator.

Another point.

I doubt this is the root cause here. But a potential optimisation to keep in mind.

You seem to be using your model - Item - (which conforms to hashable) as the ItemIdentifierType of the diffable data source.

UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>

This is indeed how many tutorials show it, yet it is no longer the recommended way. Using a hash of the entire object results in the cell being destroyed and added every time a field changes.

This is described in the docs And has been explained in WWDC 21

The optimal approach is to pass the model / view model into your cell and update the layout it if some the model properties change. Kind of like what you are doing already. Combine is handy for this. This way cells are only destroyed when an entirely new model is added or removed from the data source.

Upvotes: 1

Related Questions