【问题标题】:Dynamic-height UICollectionView inside a dynamic-height UITableViewCell动态高度 UITableViewCell 内的动态高度 UICollectionView
【发布时间】:2019-10-12 14:10:00
【问题描述】:

我有一个水平的UICollectionView 固定到UITableViewCell 的所有边缘。集合视图中的项目是动态调整大小的,我想让表格视图的高度等于最高的集合视图单元格的高度。

视图的结构如下:

UITableView

UITableViewCell

UICollectionView

UICollectionViewCell

UICollectionViewCell

UICollectionViewCell

class TableViewCell: UITableViewCell {

    private lazy var collectionView: UICollectionView = {
        let collectionView = UICollectionView(frame: self.frame, collectionViewLayout: layout)
        return collectionView
    }()

    var layout: UICollectionViewFlowLayout = {
        let layout = UICollectionViewFlowLayout()
        layout.estimatedItemSize = CGSize(width: 350, height: 20)
        layout.scrollDirection = .horizontal
        return layout
    }()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        let nib = UINib(nibName: String(describing: CollectionViewCell.self), bundle: nil)
        collectionView.register(nib, forCellWithReuseIdentifier: CollectionViewCellModel.identifier)

        addSubview(collectionView)

        // (using SnapKit)
        collectionView.snp.makeConstraints { (make) in
            make.leading.equalToSuperview()
            make.trailing.equalToSuperview()
            make.top.equalToSuperview()
            make.bottom.equalToSuperview()
        }
    }

    override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {

        collectionView.layoutIfNeeded()
        collectionView.frame = CGRect(x: 0, y: 0, width: targetSize.width , height: 1)

        return layout.collectionViewContentSize
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let spacing: CGFloat = 50
        layout.sectionInset = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing)
        layout.minimumLineSpacing = spacing
    }
}


class OptionsCollectionViewCell: UICollectionViewCell {

    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()

        contentView.translatesAutoresizingMaskIntoConstraints = false

    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {

        return contentView.systemLayoutSizeFitting(CGSize(width: targetSize.width, height: 1))
    }
}

class CollectionViewFlowLayout: UICollectionViewFlowLayout {

    private var contentHeight: CGFloat = 500
    private var contentWidth: CGFloat = 200

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        if let attrs = super.layoutAttributesForElements(in: rect) {
           // perform some logic
           contentHeight = /* something */
           contentWidth = /* something */
           return attrs
        }
        return nil
    }

    override var collectionViewContentSize: CGSize {
        return CGSize(width: contentWidth, height: contentHeight)
    }
}

在我的UIViewController 中,我有tableView.estimatedRowHeight = 500tableView.rowHeight = UITableView.automaticDimension

我正在尝试使systemLayoutSizeFitting 中的表格视图单元格的大小等于collectionViewContentSize,但在布局有机会将collectionViewContentSize 设置为正确值之前调用它。以下是这些方法的调用顺序:

cellForRowAt
CollectionViewFlowLayout collectionViewContentSize:  (200.0, 500.0) << incorrect/original value
**OptionsTableViewCell systemLayoutSizeFitting (200.0, 500.0) << incorrect/original value**
CollectionViewFlowLayout collectionViewContentSize:  (200.0, 500.0) << incorrect/original value
willDisplay cell
CollectionViewFlowLayout collectionViewContentSize:  (200.0, 500.0) << incorrect/original value
TableViewCell layoutSubviews() collectionViewContentSize.height:  500.0 << incorrect/original value
CollectionViewFlowLayout collectionViewContentSize:  (200.0, 500.0) << incorrect/original value
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize:  (450.0, 20.0)
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize:  (450.0, 20.0)
CollectionViewCell systemLayoutSizeFitting
CollectionViewCell systemLayoutSizeFitting
CollectionViewCell systemLayoutSizeFitting
CollectionViewFlowLayout collectionViewContentSize:  (450.0, 20.0)
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize:  (450.0, 301.0) << correct size
CollectionViewFlowLayout collectionViewContentSize:  (450.0, 301.0) << correct size
TableViewCell layoutSubviews() collectionViewContentSize.height:  301.0 << correct height
CollectionViewFlowLayout layoutAttributesForElements
CollectionViewFlowLayout collectionViewContentSize:  (450.0, 301.0) << correct size
TableViewCell layoutSubviews() collectionViewContentSize.height:  301.0 << correct height

如何使我的表格视图单元格的高度等于我的集合视图的最高项目,由布局的collectionViewContentSize 表示?

【问题讨论】:

  • 你能附上一张你真正想要的图片吗?
  • 您需要先计算所有单元格的高度才能找到最高的!这可能会出现性能问题。也许它包含一个比整个 tableview 高的单元格。你介意吗?
  • 你没有得到答案吗?

标签: ios swift uitableview uicollectionview


【解决方案1】:

我设法通过 CollectionView 的高度约束参考准确地实现了您想要做的事情(但是我不使用 SnapKit,也不使用 programaticUI)。

这是我的做法,它可能会有所帮助:

  1. 首先我为 UICollectionView 注册了一个 NSLayoutConstraint,它已经有一个 topAnchor 和一个等于其父视图的 bottomAnchor

    class ChatButtonsTableViewCell: UITableViewCell {
    
        @IBOutlet weak var containerView: UIView!
        @IBOutlet weak var buttonsCollectionView: UICollectionView!
        @IBOutlet weak var buttonsCollectionViewHeightConstraint: NSLayoutConstraint! // reference
    
    }
    
  2. 然后在我的UITableViewDelegate 实现中,在 cellForRowAt 上,我将高度约束的常量设置为等于实际的 collectionView 框架,如下所示:

     func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
     // let item = items[indexPath.section]
         let item = groupedItems[indexPath.section][indexPath.row]
         let cell = tableView.dequeueReusableCell(withIdentifier: ChatButtonsTableViewCell.identifier, for: indexPath) as! ChatButtonsTableViewCell
    
         cell.chatMessage = nil // reusability 
         cell.buttonsCollectionView.isUserInteractionEnabled = true
         cell.buttonsCollectionView.reloadData() // load actual content of embedded CollectionView
         cell.chatMessage = groupedItems[indexPath.section][indexPath.row] as? ChatMessageViewModelChoicesItem
    
    
         // >>> Compute actual height 
         cell.layoutIfNeeded()
         let height: CGFloat = cell.buttonsCollectionView.collectionViewLayout.collectionViewContentSize.height
         cell.buttonsCollectionViewHeightConstraint.constant = height
         // <<<
    
         return cell
    
     }
    

我的假设是您可以使用此方法将 collectionView 的高度设置为最大单元格的高度,将 let height: CGFloat = cell.buttonsCollectionView.collectionViewLayout.collectionViewContentSize.height 替换为您想要的实际高度。

另外,如果您使用的是水平 UICollectionView,我猜您甚至不必更改此行,因为 contentSize.height 将等于您最大的单元格高度。

它对我来说完美无缺,希望对你有帮助

编辑

只是想想,您可能需要在这里和那里放置一些layoutIfNeeded()invalidateLayout()。如果它不能立即起作用,我可以告诉你我把这些放在哪里。

编辑 2

另外忘了说我实现了willDisplayestimatedHeightForRowAt

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    
    if let buttonCell = cell as? ChatButtonsTableViewCell {
        cellHeights[indexPath] = buttonCell.buttonsCollectionView.collectionViewLayout.collectionViewContentSize.height
    } else {
        cellHeights[indexPath] = cell.frame.size.height
    }
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return cellHeights[indexPath] ?? 70.0
}

【讨论】:

  • 似乎 AutoLayout 无法同时满足约束。存在与buttonsCollectionViewHeightConstraint 冲突的 UIView-Encapsulated-Layout-Height 约束。我认为从cellForRowAt 更改高度约束并不安全。如果它适合你,你介意链接到 GitHub 吗?
  • 这是我为一家公司做的一个项目,我无法将 repo 链接到你。但是,请尝试将 ButtonsCollectionViewHeightConstraint 的优先级降低到 999 而不是 1000。正如您在我发送的屏幕截图中看到的那样,约束是虚线的,表示它的优先级较低。试一试。 (.source:stackoverflow.com/a/25795758/5464805)
  • 我无法在tableView(_:cellForRowAt) 中设置buttonsCollectionViewHeightConstraint,因为它在集合视图单元格有机会正确绘制其边界之前就被调用了。
  • 刚刚编辑了我的答案。我忘了我实现了 willDisplay 和estimatedHeightForRowAt。
  • tableView(_:willDisplay:forRowAt)tableView(_:estimatedHeightForRowAt:) 在集合视图单元布局之前也被调用。 cellHeights 仅在滚动表格视图以填充数组后才有帮助;第一次布局每个表格视图单元格时它没有那么有用,因为在其集合视图布局可以计算高度之后等待重置行高会产生抖动。
【解决方案2】:

有一些步骤可以实现这一点:

  • 定义自定大小UICollectionViewCell
  • 定义自我调整大小UICollectionView
  • 定义自我调整大小UITableViewCell
  • 计算所有像元大小并找到最高的(可能很昂贵)
  • 重新加载任何孩子的bounds 的高阶视图已更改

- 定义自定大小 UICollectionViewCell

您有两种选择: - 使用自动布局和自定义 UI 元素(如 UIImageViewUILabel 等) - 计算并提供你自己的尺寸

- 使用自动布局和自定义 UI 元素:

如果您将estimatedItemsSize 设置为automatic 并使用self-sized UI 元素正确布局单元格子视图,它将自动调整大小。 请注意由于复杂的布局而导致昂贵的布局时间和滞后。特别是在旧设备上。

- 计算并提供您自己的尺寸:

可以设置单元大小的最佳位置之一是intrinsicContentSize。虽然还有其他地方可以做到这一点(例如在 flowLayout 类等中),但这是您可以保证单元格不会与其他自调整大小的 UI 元素冲突的地方:

override open var intrinsicContentSize: CGSize { return /*CGSize you prefered*/ }

- 定义自定大小 UICollectionView

任何UIScrollView 子类都可以与contentSize 一起调整大小。由于 collectionView 和 tableView 内容大小不断变化,您应该通知layouter(在任何地方都没有正式称为布局器):

protocol CollectionViewSelfSizingDelegate: class {
    func collectionView(_ collectionView: UICollectionView, didChageContentSizeFrom oldSize: CGSize, to newSize: CGSize)
}

class SelfSizingCollectionView: UICollectionView {

    weak var selfSizingDelegate: CollectionViewSelfSizingDelegate?

    override open var intrinsicContentSize: CGSize {
        return contentSize
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        if let floawLayout = collectionViewLayout as? UICollectionViewFlowLayout {
            floawLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
        } else {
            assertionFailure("Collection view layout is not flow layout. SelfSizing may not work")
        }
    }

    override open var contentSize: CGSize {
        didSet {
            guard bounds.size.height != contentSize.height else { return }
            frame.size.height = contentSize.height
            invalidateIntrinsicContentSize()

            selfSizingDelegate?.collectionView(self, didChageContentSizeFrom: oldValue, to: contentSize)
        }
    }
}

因此,在覆盖 intrinsicContentSize 之后,我们会观察 contentSize 的变化并通知需要了解这一点的 selfSizingDelegate

- 定义自定大小 UITableViewCell

这些单元格可以通过在它们的tableView 中实现来调整大小:

override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return UITableView.automaticDimension
}

我们将在这个单元格中放置一个selfSizingCollectionView 并实现这个:

class SelfSizingTableViewCell: UITableViewCell {

    @IBOutlet weak var collectionView: SelfSizingCollectionView!

    override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
        return collectionView.frame.size
    }

    override func awakeFromNib() {
        ...
        collectionView.selfSizingDelegate = self
    }
}

所以我们符合委托并实现更新单元格的功能。

extension SelfSizingTableViewCell: CollectionViewSelfSizingDelegate {

    func collectionView(_ collectionView: UICollectionView, didChageContentSizeFrom oldSize: CGSize, to newSize: CGSize) {
        if let indexPath = indexPath {
            tableView?.reloadRows(at: [indexPath], with: .none)
        } else {
            tableView?.reloadData()
        }
    }
}

请注意,我为 UIViewUITableViewCell 编写了一些辅助扩展来访问父表视图:

extension UIView {

    var parentViewController: UIViewController? {
        var parentResponder: UIResponder? = self
        while parentResponder != nil {
            parentResponder = parentResponder!.next
            if let viewController = parentResponder as? UIViewController {
                return viewController
            }
        }
        return nil
    }
}

extension UITableViewCell {

    var tableView: UITableView? {
        return (next as? UITableView) ?? (parentViewController as? UITableViewController)?.tableView
    }

    var indexPath: IndexPath? {
        return tableView?.indexPath(for: self)
    }
}

到目前为止,您的集合视图将具有完全需要的高度(如果它是垂直的),但您应该记住限制所有单元格宽度,以免超出屏幕宽度。

extension SelfSizingTableViewCell: UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: frame.width, height: heights[indexPath.row])
    }
}

通知heights ?这是您应该缓存(手动/自动)计算的所有单元格高度的地方,以免变得迟钝。 (出于测试目的,我随机生成所有这些)

- 最后两步:

已经介绍了最后两个步骤,但您应该考虑有关您寻求的案例的一些重要事项:

1 - 如果您要求自动布局为您计算所有单元格大小,它可能会成为史诗级性能杀手,但它是可靠的。

2 - 您必须缓存所有尺寸并获得最高的尺寸并将其返回 tableView(:,heightForRowAt:)(不再需要自行调整尺寸 tableViewCell

3 - 考虑到最高的可能比整个 tableView 高,并且 2D 滚动会很奇怪。

4 - 如果您重复使用包含 collectionViewtableView 的单元格,则滚动偏移、插入和许多其他属性的行为会以您意想不到的方式出现。

这是你能遇到的最复杂的布局之一,但一旦你习惯了它,它就会变得简单。

【讨论】:

  • 嗨@Mojtaba Hosseini,我在UICollectionview 上方也有一个标签。在这种情况下,我该如何计算高度。
  • 请测试您的方式并提出一个新问题。在 cmets 中提问和回答问题是违反规则的。
【解决方案3】:

几天前我遇到了类似的问题,在UITableViewCell 中获得了UICollectionView 的正确高度。没有一个解决方案奏效。我终于找到了resizing cells on device rotate的解决方案

简而言之,我用函数更新表格视图:

- (void) reloadTableViewSilently {
   dispatch_async(dispatch_get_main_queue(), ^{
      [self.tableView beginUpdates];
      [self.tableView endUpdates];
   });
}

函数在viewWillAppearviewWillTransitionToSize: withTransitionCoordinator中调用

即使在设备旋转时也能发挥魅力。希望这会有所帮助。

【讨论】:

    【解决方案4】:

    它在您的代码中不起作用,因为在 collectionView 完成布局之前调用了单元格的 systemLayoutSizeFitting。为了让它工作,你需要在collectionView完成布局时更新表格视图布局。

    我写了关于如何做self sizing with collection view inside table view cell here的详细说明。我还包括sample codes here。以下是它的要点:

    1. 集合视图布局完成时捕获事件。如果您继承UICollectionView,请覆盖layoutSubviews。如果您继承 UIViewControllerUICollectionViewController,请覆盖 viewDidLayoutSubviews
    2. 将此事件处理程序连接到表视图单元格以在集合视图完成布局时更新表视图布局。
    3. 在表格视图单元格中覆盖 systemLayoutSizeFitting 以返回集合视图的 contentSize,您已经这样做了。
    4. 当您只需要更新其布局(自动高度)时,不要重新加载表格行。我在这里看到一些建议 reloadData 或 reloadRows 的答案。如果您的收藏视图很重,请不要这样做。一个简单的tableView.beginUpdates()tableView.endUpdates() 将在不重新加载行的情况下更新行高。
    class CollectionViewController: UICollectionViewController {
        
        //  Set this action during initialization to get a callback when the collection view finishes its layout.
        //  To prevent infinite loop, this action should be called only once. Once it is called, it resets itself
        //  to nil.
        var didLayoutAction: (() -> Void)?
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            
            didLayoutAction?()
            didLayoutAction = nil   //  Call only once
        }
    }
    
    class CollectionViewTableViewCell: UITableViewCell {
        
        var collectionViewController: CollectionViewController!
    
        override func awakeFromNib() {
            super.awakeFromNib()
            
            collectionViewController = CollectionViewController(nibName: String(describing: CollectionViewController.self), bundle: nil)
            
            //  Need to update row height when collection view finishes its layout.
            collectionViewController.didLayoutAction = updateRowHeight
            
            contentView.addSubview(collectionViewController.view)
            collectionViewController.view.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                collectionViewController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
                collectionViewController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
                collectionViewController.view.topAnchor.constraint(equalTo: contentView.topAnchor),
                collectionViewController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
            ])
        }
    
        private func updateRowHeight() {
            DispatchQueue.main.async { [weak self] in
                self?.tableView?.updateRowHeightsWithoutReloadingRows()
            }
        }
    }
    
    extension UITableView {
        func updateRowHeightsWithoutReloadingRows(animated: Bool = false) {
            let block = {
                self.beginUpdates()
                self.endUpdates()
            }
            
            if animated {
                block()
            }
            else {
                UIView.performWithoutAnimation {
                    block()
                }
            }
        }
    }
    
    extension UITableViewCell {
        var tableView: UITableView? {
            return (next as? UITableView) ?? (parentViewController as? UITableViewController)?.tableView
        }
    }
    
    extension UIView {
        var parentViewController: UIViewController? {
            if let nextResponder = self.next as? UIViewController {
                return nextResponder
            } else if let nextResponder = self.next as? UIView {
                return nextResponder.parentViewController
            } else {
                return nil
            }
        }
    }
    

    另外请注意,如果您的应用目标是 iOS 14,请考虑使用新的 UICollectionLayoutListConfiguration。它使集合视图布局就像表格视图行一样。这是WWDC-20 video,它显示了如何做到这一点。

    【讨论】:

    • 是否有这样的版本,其中每一行都是一个集合视图并且它来自从 Firebase 之类的东西加载的数据?我已尝试使用您的代码示例,并且确实看到了我的数据,但大小不正确。
    • 这是 100% 正确的解决方案。
    猜你喜欢
    • 2014-07-30
    • 1970-01-01
    • 2019-07-30
    • 2021-05-18
    • 1970-01-01
    • 1970-01-01
    • 2016-07-27
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多