swiftuicollectionviewuikit

When scrolling, the cell height is not calculated correctly?


Good afternoon everyone. I have a collection with variable cell height. When scrolling a collection, the height of the content does not match the height of the cell. Tell me how to solve this problem?

This is how I create a collection.

// MARK: - UI Fabric.
private extension CollectionViewController {
    func createCollectionView() -> UICollectionView {
        let layout = UICollectionViewFlowLayout()
        layout.minimumInteritemSpacing = 16
        layout.minimumLineSpacing = 16
        layout.scrollDirection = .vertical
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.showsVerticalScrollIndicator = false
        collectionView.translatesAutoresizingMaskIntoConstraints = false

        return collectionView
    }
}

This is delegate gor setting size cell.

// MARK: - CollectionView Flow Layout.
extension CollectionViewController: UICollectionViewDelegateFlowLayout {
    /// Setting cell sizes.
    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath
    ) -> CGSize {
        delegateSettingCell.calculateCellSize(screenWidth: view.bounds.width, index: indexPath.row)
    }
}

Here I am calculating the cell size.

    func calculateCellSize(screenWidth: CGFloat, index: Int) -> CGSize {
        let item = modelForDisplay[index].addonDetail.count
        let heightCell = CGFloat((item * 44) + 44)
        let sizeCellWidth = (view.bounds.width - 32)
        return CGSize(width: sizeCellWidth, height: heightCell)
    }

enter image description here

enter image description here

I made a size display by clicking on a cell. and after scrolling. the size does not change. enter image description here


final class AddonCreatorCell: UICollectionViewCell {

    // MARK: - Dependencies
    var delegate: IHandlerAddonCreatorCellDelegate?

    // MARK: - Public properties
    static var reuseIdentifier: String = "AddonCreatorCell.cell"

    // MARK: - Private properties
    private lazy var textFields: [UITextField] = []
    private lazy var viewDie = createView()
    private lazy var gradientViewDie = createGradient(GradientColors.yellowGradient)
    private lazy var labelTitle = createUILabel()
    private lazy var switchShow = createSwitch()
    private lazy var vStack = createStack()
    private lazy var headerStack = createStack()

    // MARK: - Initializator
    override init(frame: CGRect) {
        super.init(frame: frame)
        addUIView()
        setupConfiguration()
        setupLayout()
    }

    convenience init(handlerAddonCreatorCellDelegate: IHandlerAddonCreatorCellDelegate?) {
        self.init(frame: CGRect.zero)
        self.delegate = handlerAddonCreatorCellDelegate
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }


    // MARK: - Public methods
    func reloadFrame(size: CGSize) {
        self.gradientViewDie.frame.size = size

    }

    func reloadData(model: CreateAddon) {
    // Here I delete unnecessary UIStask with content
        vStack.subviews.forEach {
            if $0 != headerStack {
                $0.removeFromSuperview()
            }
        }

        labelTitle.text = "\(model.title) \(model.addonDetail.count)"

        model.isActive ? activeSwitch() : deactivateSwitch()
        switchShow.setOn(model.isActive, animated: false)

        for (index, element) in model.addonDetail.enumerated() {
            createContent(item: element, index: index)
        }
    }

    private func createContent(item: AddonDetail, index: Int) {
        let convertToString = String(item.count)
        let label = createUILabel()
        let textField = createTextField(id: index, text: convertToString)
        let hStack = createStack()

        label.text = item.title
        textFields.append(textField)
        textField.delegate = self
        textField.addTarget(self, action: #selector(self.myTextFieldChanged(_:)), for: .editingChanged)

        hStack.axis = .horizontal
        hStack.alignment = .center
        hStack.distribution = .fillProportionally
        hStack.addArrangedSubview(label)
        hStack.addArrangedSubview(textField)
        vStack.addArrangedSubview(hStack)
        addConstraintTextField(textField)
    }
}


Solution

  • You don't need to "re-build" your reusable cell every time...

    Think of the stack view as "rows"

    If you know the max number of rows any individual cell will have, create them in the cell's init.

    Then, when you set the data in cellForItemAt, show/hide the "rows" as needed. Something like this:

        // show used "rows" and hide unused "rows"
        for (i, v) in vStack.arrangedSubviews.enumerated() {
            v.isHidden = !(i < model.addons.count)
        }
    

    If you don't know the potential max number of rows, create them as needed (again, when setting the cell data in cellForItemAt:

        // if we have fewer "rows" than addons, create new "rows"
        while vStack.arrangedSubviews.count < model.addons.count {
            // add a new textfield & switch
            vStack.addArrangedSubview(...)
        }
        // show used "rows" and hide unused "rows"
        for (i, v) in vStack.arrangedSubviews.enumerated() {
            v.isHidden = !(i < model.addons.count)
        }
    

    When an arrangedSubview is hidden, it still exists, but the stack view treats it as if it isn't there.

    Here's a complete example - based loosely on the code in your question:

    struct Addon {
        var text: String = ""
        var selected: Bool = false
    }
    struct CreateAddon {
        var title: String = ""
        var addons: [Addon] = []
    }
    class CollectionViewController: UIViewController {
    
        var myData: [CreateAddon] = []
        
        var collectionView: UICollectionView!
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            collectionView = createCollectionView()
            collectionView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(collectionView)
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            ])
            
            collectionView.register(AddonCreatorCell.self, forCellWithReuseIdentifier: AddonCreatorCell.reuseIdentifier)
            collectionView.dataSource = self
            collectionView.delegate = self
            
            view.backgroundColor = .init(red: 0.108, green: 0.379, blue: 0.129, alpha: 1.0)
            collectionView.backgroundColor = .init(red: 0.108, green: 0.379, blue: 0.129, alpha: 1.0)
    
            // create some sample data
            let numRows: [Int] = [3, 5, 2, 6, 4, 3, 3, 4, 7, 5, 3, 4, 6, 5, 6, 2]
            for (row, n) in numRows.enumerated() {
                var theseAddons: [Addon] = []
                for i in 0..<n {
                    let a: Addon = Addon(text: "User Text \(i)", selected: false)
                    theseAddons.append(a)
                }
                let cr = CreateAddon(title: "Addon Title \(row)", addons: theseAddons)
                myData.append(cr)
            }
            
        }
        
    }
    extension CollectionViewController: UICollectionViewDataSource, UICollectionViewDelegate {
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return myData.count
        }
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let c = collectionView.dequeueReusableCell(withReuseIdentifier: AddonCreatorCell.reuseIdentifier, for: indexPath) as! AddonCreatorCell
            c.fillData(model: myData[indexPath.item])
            // cell's "die" view width -- it has 8-points "spacing" on each side
            c.wConstraint.constant = collectionView.frame.width - 16.0
            return c
        }
    }
    
    private extension CollectionViewController {
        func createCollectionView() -> UICollectionView {
            let layout = UICollectionViewFlowLayout()
            layout.estimatedItemSize = .init(width: 300.0, height: 50.0)
            layout.minimumInteritemSpacing = 16
            layout.minimumLineSpacing = 16
            layout.scrollDirection = .vertical
            let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
            collectionView.showsVerticalScrollIndicator = false
            return collectionView
        }
    }
    
    class TextFieldSwitch: UIView {
        
        private let theTextField = UITextField()
        private let theSwitch = UISwitch()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            let sv = UIStackView()
            sv.spacing = 8
            sv.addArrangedSubview(theTextField)
            sv.addArrangedSubview(theSwitch)
            self.addSubview(sv)
            sv.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                sv.topAnchor.constraint(equalTo: self.topAnchor, constant: 0.0),
                sv.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 0.0),
                sv.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0.0),
                sv.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0.0),
            ])
            theTextField.borderStyle = .bezel
            // make sure the switch doesn't stretch
            theSwitch.setContentHuggingPriority(.required, for: .horizontal)
        }
        public func fillData(d: Addon) {
            theTextField.text = d.text
            theSwitch.isOn = d.selected
        }
        
    }
    final class AddonCreatorCell: UICollectionViewCell {
        
        // we'll use this to make the cells the width of the collection view
        public var wConstraint: NSLayoutConstraint!
        
        // MARK: - Dependencies
        //var delegate: IHandlerAddonCreatorCellDelegate?
        
        // MARK: - Public properties
        static var reuseIdentifier: String = "AddonCreatorCell.cell"
        
        // MARK: - Private properties
        private let viewDie = AddonGradientView()
        private let labelTitle = UILabel() // createUILabel()
        private let vStack = UIStackView() // createStack()
        
        // MARK: - Initializator
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            vStack.axis = .vertical
            vStack.spacing = 4
            
            for v in [viewDie, labelTitle, vStack] {
                v.translatesAutoresizingMaskIntoConstraints = false
                contentView.addSubview(v)
            }
            
            let g = contentView
            
            // this will be updated in cellForItemAt
            //  it will keep the cells the full width of the collection view
            wConstraint = viewDie.widthAnchor.constraint(equalToConstant: 300.0)
            
            let bConstraint = vStack.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0)
            
            // giving these two constraints less-than-required priority avoids auto-layout complaints
            wConstraint.priority = .required - 1
            bConstraint.priority = .required - 1
            
            NSLayoutConstraint.activate([
    
                viewDie.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
                viewDie.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
                viewDie.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
                viewDie.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
    
                labelTitle.topAnchor.constraint(equalTo: g.topAnchor, constant: 16.0),
                labelTitle.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                labelTitle.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
    
                vStack.topAnchor.constraint(equalTo: labelTitle.bottomAnchor, constant: 8.0),
                vStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                vStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                bConstraint,
    
                wConstraint,
                
            ])
            
            viewDie.colors = [
                .init(red: 0.918, green: 0.990, blue: 0.325, alpha: 1.0),
                .init(red: 0.824, green: 0.894, blue: 0.242, alpha: 1.0),
                .init(red: 0.918, green: 0.990, blue: 0.325, alpha: 1.0),
            ]
            
            viewDie.layer.cornerRadius = 16
        }
        
        
        // MARK: - Public methods
        
        func fillData(model: CreateAddon) {
            // if we have fewer "rows" than addons, create new "rows"
            while vStack.arrangedSubviews.count < model.addons.count {
                vStack.addArrangedSubview(TextFieldSwitch())
            }
            // configure "rows"
            for (row, addon) in zip(vStack.arrangedSubviews, model.addons) {
                if let v = row as? TextFieldSwitch {
                    v.fillData(d: addon)
                }
                
            }
            // show used "rows" and hide unused "rows"
            for (i, v) in vStack.arrangedSubviews.enumerated() {
                v.isHidden = !(i < model.addons.count)
            }
            labelTitle.text = model.title
        }
        
    }
    
    class AddonGradientView: UIView {
        
        public var colors: [UIColor] = [.gray, .white] {
            didSet {
                gradientLayer.colors = colors.map { $0.cgColor }
            }
        }
        
        override class var layerClass: AnyClass { CAGradientLayer.self }
        private var gradientLayer: CAGradientLayer { layer as! CAGradientLayer }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            self.backgroundColor = .clear
            gradientLayer.colors = colors.map { $0.cgColor }
        }
        
    }
    

    Looks like this:

    enter image description here

    enter image description here

    Note: I didn't implement any of the data-updating when the user types in the text fields or toggles the switches ... I'll leave that up to you.

    As a side note: for a single-column layout like this, a UITableView is easier to manage than a UICollectionView