You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS11+兼容UICollectionView自适配高度折叠Cell布局异常求解

iOS11+兼容UICollectionView可折叠动态高度Cell实现问题

我要实现支持可折叠展开、动态高度Cell的UICollectionView,要求兼容iOS11及以上版本,因此不使用Compositional Layout。动态高度Cell的基础实现逻辑很简单:


核心实现代码

自定义FlowLayout及ViewController代码

final class CustomFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let layoutAttributesObjects = super.layoutAttributesForElements(in: rect)?.map{ $0.copy() } as? [UICollectionViewLayoutAttributes]
        layoutAttributesObjects?.forEach({ layoutAttributes in
            if layoutAttributes.representedElementCategory == .cell {
                if let newFrame = layoutAttributesForItem(at: layoutAttributes.indexPath)?.frame {
                    layoutAttributes.frame = newFrame
                }
            }
        })
        return layoutAttributesObjects
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let collectionView = collectionView else {
            fatalError()
        }
        guard let layoutAttributes = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes else {
            return nil
        }

        layoutAttributes.frame.origin.x = sectionInset.left
        layoutAttributes.frame.size.width = collectionView.safeAreaLayoutGuide.layoutFrame.width - sectionInset.left - sectionInset.right
        return layoutAttributes
    }
}

class ViewController: UIViewController, CollapsingCellDelegate {
    let items = [
                "Lorem ipsum dolor sit amet.",
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.",
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.",
                "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
                "Lorem ipsum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
        ]

    private var collapsedSections: [IndexPath] = []
    private var collapsedItems: [IndexPath] = []

    private lazy var collectionView: UICollectionView = {
        let layout = CustomFlowLayout()
        layout.scrollDirection = .vertical
        layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
        return UICollectionView(frame: .zero, collectionViewLayout: layout)
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.backgroundColor = .black
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.contentInsetAdjustmentBehavior = .never
        collectionView.showsVerticalScrollIndicator = false
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.dataSource = self
        collectionView.register(CollapsingCell.self, forCellWithReuseIdentifier: CollapsingCell.identifier)
        view.addSubview(collectionView)

        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    // 折叠/展开核心逻辑
    func didTriggerCollapseEvent(cell: CollapsingCell) {
        guard let indexPath = collectionView.indexPath(for: cell) else { return }
        if collapsedItems.contains(indexPath) {
            self.collapsedItems.removeAll(where: { $0 == indexPath })
        } else {
            collapsedItems.append(indexPath)
        }
        UIView.animate(withDuration: 0.8, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.9, options: .curveEaseInOut, animations: {
            self.collectionView.reloadItems(at: [indexPath])
        })
    }
}

extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let myCell = collectionView.dequeueReusableCell(withReuseIdentifier: CollapsingCell.identifier, for: indexPath) as! CollapsingCell
        myCell.isCollapsed = collapsedItems.contains(indexPath)
        myCell.delegate = self
        myCell.configure(title: "Cell #\(indexPath.row)", contentTitle: items[indexPath.row])
        return myCell
    }
}

可折叠CollectionViewCell代码

protocol CollapsingCellDelegate: AnyObject {
    func didTriggerCollapseEvent(cell: CollapsingCell)
}

public class CollapsingCell: UICollectionViewCell {
    var isCollapsed: Bool? {
        didSet {
            contentLabel.isHidden = isCollapsed ?? false
        }
    }

    static var identifier: String {
        String(describing: self)
    }

    weak var delegate: CollapsingCellDelegate?
    private let titleLabel = UILabel()
    private let collapseButton = UIButton(type: .system)
    private let stackView = UIStackView()
    private let contentLabel = UILabel()

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

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupViews() {
        contentView.clipsToBounds = true
        contentView.layer.cornerRadius = 9
        contentView.backgroundColor = .white

        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.axis = .vertical
        contentView.addSubview(stackView)

        let headerView = UIView()
        headerView.translatesAutoresizingMaskIntoConstraints = false
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        titleLabel.textColor = .black
        titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
        headerView.addSubview(titleLabel)
        collapseButton.addTarget(self, action: #selector(collapseButtonTapped), for: .touchUpInside)
        collapseButton.setTitle("Collapse/Expend", for: .normal)
        collapseButton.translatesAutoresizingMaskIntoConstraints = false
        headerView.addSubview(collapseButton)
        stackView.addArrangedSubview(headerView)

        contentLabel.numberOfLines = 0
        contentLabel.lineBreakMode = .byWordWrapping
        contentLabel.textColor = .black
        contentLabel.translatesAutoresizingMaskIntoConstraints = false
        stackView.addArrangedSubview(contentLabel)

        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: headerView.topAnchor, constant: 16),
            titleLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16),
            titleLabel.bottomAnchor.constraint(equalTo: headerView.bottomAnchor, constant: -16),
            collapseButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
            collapseButton.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 16),

            stackView.topAnchor.constraint(equalTo: contentView.topAnchor),
            stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }

    @objc func collapseButtonTapped() {
        delegate?.didTriggerCollapseEvent(cell: self)
    }

    func configure(title: String, contentTitle: String) {
        titleLabel.text = title
        contentLabel.text = contentTitle
    }

    public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        let targetSize = CGSize(width: layoutAttributes.frame.width, height: 0)
        let size = contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
        layoutAttributes.frame.size = size
        return layoutAttributes
    }
}

现有表现

未进行折叠展开操作时运行正常,Cell内部使用基础StackView,包含Header和内容视图两个部分:点击折叠按钮时,内容视图切换可见性,StackView会同步修改自身尺寸进而改变Cell尺寸,仅需要刷新对应Cell即可完成状态更新。


存在问题

列表前几个Cell折叠功能正常,操作列表底部的Cell折叠时,UICollectionView会出现布局错乱。

已尝试修改animateWithDuration代码块、在performBatchUpdates块内执行刷新指定item/刷新整个列表/invalidateLayout操作,但最好的结果也只是出现其他异常bug,未解决核心问题。


内容的提问来源于stack exchange,提问作者Serjio

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.06 02:27:00