使用自动布局实现UICollectionView头部动态高度

50

我有一个使用UICollectionView的视图,其中包含一个类型为UICollectionReusableView的头部。

在该头部中,我有一个标签其长度由用户输入决定。

我正在寻找一种方法,使头部根据标签的高度以及头部中的其他子视图动态调整大小。

这是我的故事板:

storyboard

当我运行应用程序时,就会得到以下结果:

result


https://dev59.com/eJjga4cB1Zd3GeqPFRvT - joda
可能问题是重复的:https://dev59.com/EF8e5IYBdhLWcg3wja1I - Ahmad F
@Ahmad,第一种方法对我不起作用,第二种方法我不知道如何创建标题的nib来尝试它,如果你有任何关于此的想法,请帮帮我。 - joda
6个回答

79

这里有一个优雅、最新的解决方案。

正如其他人所说,首先确保您拥有的所有约束都从您的标题视图顶部开始,直到第一个子视图的顶部,从第一个子视图的底部到第二个子视图的顶部等等,并且从最后一个子视图的底部到您的标题视图的底部。只有这样自动布局才能知道如何调整视图的大小。

以下代码片段返回计算出的标题视图大小。

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {

    // Get the view for the first header
    let indexPath = IndexPath(row: 0, section: section)
    let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)

    // Use this view to calculate the optimal size based on the collection view's width
    return headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
                                              withHorizontalFittingPriority: .required, // Width is fixed
                                              verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
}

编辑

正如@Caio在评论中指出的那样,这种解决方案会导致iOS 10及更早版本崩溃。 在我的项目中,我通过将上面的代码包装在if #available(iOS 11.0, *) { ... }中并在else子句中提供固定大小来“解决”这个问题。虽然不是最理想的,但在我的情况下是可以接受的。


4
最佳答案,但需要注意使用 systemLayoutSizeFitting 方法时,由于插入和边距的影响,collectionView.frame.width 可能会更小...您可以通过使用以下方法更加精确:let bounds = collectionView.bounds let layout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout let width = bounds.size.width - layout.sectionInset.left - layout.sectionInset.right - layoutMargins.left - layoutMargins.right - Peter Lapisu
2
你的解决方案可以让我的单元格正确调整大小,但是我收到了大量的NSLayoutUnsatisfiableConstraint错误。同一个单元格在另一个集合视图中用作“普通”内容单元格,那里它可以正确调整大小。我使用了这个教程来实现动态单元格调整大小:https://medium.com/@andrea.toso/uicollectionviewcell-dynamic-height-swift-b099b28ddd23再说一遍,它适用于另一个集合视图中的常规单元格,但如果我将其用作标题单元格,则在此集合视图中不起作用。你有什么想法为什么会出现这种情况吗? - linus_hologram
2
请注意,它在iOS 9/10上无法工作。由于您无法在委托调用collectionView(collectionView:kind:at)之前取消排队补充视图。这将导致崩溃。 - Caio
5
这似乎是正确的方法,然而,在方法调用“viewForSupplementaryElementOfKind”中有一个隐匿的 bug。它的实现调用了“dequeueReusableSupplementaryView()”,导致另一个HeaderView的实例被创建。由于“referenceSizeForHeaderInSection”经常被调用,会创建大量的HeaderView,而且如果使用了“sectionHeadersPinToVisibleBounds”并将其设置为“true”,还会出现视觉上的错误。作为解决方案,HeaderView可以是一个懒加载属性:“lazy var headerViewToMeasureHeight: UIView = { ... }()”。 - podkovyr
1
它对我不起作用,因为视图在引用方法调用时未被创建,数据源方法稍后调用。 - Zaporozhchenko Oleksandr
显示剩余2条评论

36

这让我抓狂了半天,以下是最终行得通的方法:

确保标题标签设置为动态调整大小、单行和换行

  1. 将标签嵌入视图中。这将有助于自适应大小。 enter image description here

  2. 确保标签上的约束是有限制的。例如:从底部标签到可重用视图的大于约束将无效。请参见上面的图片。

  3. 为您子类中嵌入标签的视图添加一个输出口。

class CustomHeader: UICollectionReusableView {
    @IBOutlet weak var contentView: UIView!
}
  • 使初始布局失效

  • override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
    
        collectionView.collectionViewLayout.invalidateLayout()
    }
    
  • 布局标题以获得正确的大小

  • extension YourViewController: UICollectionViewDelegate {
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
    
            if let headerView = collectionView.visibleSupplementaryViews(ofKind: UICollectionElementKindSectionHeader).first as? CustomHeader {
                // Layout to get the right dimensions
                headerView.layoutIfNeeded()
    
                // Automagically get the right height
                let height = headerView.contentView.systemLayoutSizeFitting(UILayoutFittingExpandedSize).height
    
                // return the correct size
                return CGSize(width: collectionView.frame.width, height: height)
            }
    
            // You need this because this delegate method will run at least 
            // once before the header is available for sizing. 
            // Returning zero will stop the delegate from trying to get a supplementary view
            return CGSize(width: 1, height: 1)
        }
    
    }
    

    6
    意思是:意识到这种方法对于较长的列表可能不起作用,因为在返回到该列表时标题将不可见。 对于较短的列表,这种方法仍然有效。 - Joe Susnick
    2
    我通过在控制器中引用页眉视图来实现这项工作。谢谢! - Julius Markūnas
    这是一个解决方案,但对于UICollectionView,无论是dequeueReusableSupplementaryView还是dequeueReusableCell计算SupplementaryView/Cell的大小都会收到警告。 - BollMose
    1
    为了让这对我起作用,对于更长的列表,当滚动时使标题不可见,我必须将标题存储为类属性(在出队时),然后在上述方法中执行:如果头部存在,则... - andrea
    1
    在创建头之前调用此方法。 - Zaporozhchenko Oleksandr

    8

    4
    这算是个答案吗?这里的self.mylabel是什么?可以解释一下吗? - Zaporozhchenko Oleksandr

    4
    您可以通过实现以下内容来实现这一目标:
    视图控制器:
    class ViewController: UIViewController {
        @IBOutlet weak var collectionView: UICollectionView!
    
        // the text that you want to add it to the headerView's label:
        fileprivate let myText = "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."
    }
    
    extension ViewController: UICollectionViewDataSource {
        func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
            switch kind {
            case UICollectionElementKindSectionHeader:
                let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind,
                                                                                 withReuseIdentifier: "customHeader",
                                                                                 for: indexPath) as! CustomCollectionReusableView
    
                headerView.lblTitle.text = myText
                headerView.backgroundColor = UIColor.lightGray
    
                return headerView
            default:
                fatalError("This should never happen!!")
            }
        }
    
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return 100
        }
    
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "customCell", for: indexPath)
    
            cell.backgroundColor = UIColor.brown
            // ...
    
            return cell
        }
    }
    
    extension ViewController: UICollectionViewDelegateFlowLayout {
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
            // ofSize should be the same size of the headerView's label size:
            return CGSize(width: collectionView.frame.size.width, height: myText.heightWithConstrainedWidth(font: UIFont.systemFont(ofSize: 17)))
        }
    }
    
    
    extension String {
        func heightWithConstrainedWidth(font: UIFont) -> CGFloat {
            let constraintRect = CGSize(width: UIScreen.main.bounds.width, height: CGFloat.greatestFiniteMagnitude)
            let boundingBox = self.boundingRect(with: constraintRect, options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
    
            return boundingBox.height
        }
    }
    

    自定义的UICollectionReusableView:
    class CustomCollectionReusableView: UICollectionReusableView {
        @IBOutlet weak var lblTitle: UILabel!
    
        override func awakeFromNib() {
            super.awakeFromNib()
    
            // setup "lblTitle":
            lblTitle.numberOfLines = 0
            lblTitle.lineBreakMode = .byWordWrapping
            lblTitle.sizeToFit() 
        }
    }
    

    2
    此解决方案未使用动态高度和自动布局。 - esbenr

    3

    我使用@Pim的答案解决了问题,但正如评论中的@linus_hologram所提到的,这种解决方案会使AutoLayout抱怨无法满足的约束条件。 我发现一个简单的解决方法:

    collectionView(_:layout:referenceSizeForHeaderInSection:) 中,不要使用 collectionView(_:viewForSupplementaryElementOfKind:at:) 获取你的 UICollectionView 实例想要重用的视图的引用,而是即时创建你的header视图类的一个实例,并添加一个宽度约束,这样AutoLayout就能计算出头部的宽度:

    let headerView = YourCustomHeaderClass() 
    headerView.translatesAutoresizingMaskIntoConstraints = false
    headerView.widthAnchor.constraint(equalToConstant: collectionView.frame.width).isActive = true
    return headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
    

    0
    对于那些需要动态布局多个不同高度的补充标题的人来说,在UICollectionViewFlowLayout中使用referenceSizeForHeaderInSection是很痛苦的。
    我建议使用UICollectionViewCompositionalLayout和.estimated()高度维度。在流式布局中卡了一个早上之后,组合布局就像魔法一样起作用:
    lazy var collectionViewLayout: UICollectionViewLayout = {
    
        let layout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
    
            // define items, group and section
            // (I'll skip some code since it's not relevant to the question)
            let itemSize = ...
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            let groupSize = NSCollectionLayoutSize(...)
            let group = NSCollectionLayoutGroup.horizontal(...)
            let section = NSCollectionLayoutSection(group: group)
    
            // define header
            // * the magic is here: use .estimated() to dynamically set height for header
            let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(72))
            let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading)
            section.boundarySupplementaryItems = [header]
    
            return section
        }
    
        return layout
    }()
    

    当然,你需要在viewDidLoad中设置布局。
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.collectionViewLayout = collectionViewLayout
    }
    

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接