Динамическая высота UICollectionView внутри динамической высоты UITableViewCell

У меня есть горизонтальный 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 = 500 и tableView.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, представленному макетом collectionViewContentSize?

Ответ 1

Есть несколько шагов для достижения этой цели:

  • Определение самоопределения UICollectionViewCell
  • Определение самоопределения UICollectionView
  • Определение собственного размера UITableViewCell
  • Рассчитайте все размеры ячеек и найдите самые высокие (может быть дорого)
  • Обновить вид более высокого порядка для любых дочерних bounds изменен

Определение размера UICollectionViewCell

Здесь у вас есть два варианта: - использовать авторазметку с автоматически изменяющимися элементами пользовательского интерфейса (такими как UIImageView или UILabel или т.д.) - рассчитать и UILabel размер самостоятельно.

- Использовать автоматическую разметку с саморазмерными элементами пользовательского интерфейса:

если вы установите estimatedItemsSize быть automatic и расположение клеток подвиды правильно с элементами selfsizing UI, это будет автоматически проклейки на лету. НО следует учитывать дорогое время компоновки и лаги из-за сложных макетов. Специально на старых устройствах.

- Рассчитайте и укажите его размер самостоятельно:

Одним из лучших мест, где вы можете установить размер ячейки, является intrinsicContentSize. Хотя для этого есть и другие места (например, в классе flowLayout и т.д.), Здесь вы можете гарантировать, что ячейка не будет конфликтовать с другими саморазмерными элементами пользовательского интерфейса:

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

- Определение самоопределения UICollectionView

Любой подкласс UIScrollView может быть самодостаточным дуэтом для него contentSize. Так как размеры контента collectionView и tableView постоянно меняются, вы должны сообщить об этом layouter (официально нигде не известно как 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()
        }
    }
}

Обратите внимание, что я написал несколько вспомогательных расширений для UIView и UITableViewCell для доступа к представлению родительской таблицы:

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 - Autolayout может быть эпическим убийцей производительности, если вы попросите его рассчитать все размеры ячеек для вас, но это надежно.

2 - Вы должны кэшировать все размеры, получить самое высокое и вернуть его в tableView(:,heightForRowAt:) (больше нет необходимости в самостоятельном tableViewCell размера tableViewCell)

3 - Считайте, что самый высокий из них может быть выше, чем весь tableView и 2D прокрутка будет странной.

4 - Если вы повторно используете ячейку, содержащую collectionView или tableView, смещение прокрутки, вставка и многие другие атрибуты ведут себя странно так, как вы этого не ожидаете.

Это один из самых сложных макетов из всех, которые вы могли встретить, но как только вы привыкнете к нему, все станет просто.

Ответ 2

Мне удалось добиться именно того, что вы хотите сделать с помощью ссылки на ограничение высоты для CollectionView (однако я не использую SnapKit, ни programaticUI).

Вот как я это сделал, это может помочь:

  1. Сначала я регистрирую NSLayoutConstraint для UICollectionView, который уже имеет topAnchor и bottomAnchor равный его суперпредставлению

height constraint on UICollectionView inside UITableViewCell

class ChatButtonsTableViewCell: UITableViewCell {

    @IBOutlet weak var containerView: UIView!
    @IBOutlet weak var buttonsCollectionView: UICollectionView!
    @IBOutlet weak var buttonsCollectionViewHeightConstraint: NSLayoutConstraint! // reference

}
  1. Затем в моей реализации UITableViewDelegate в cellForRowAt я установил константу ограничения высоты, равную рамке actall 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 на фактическую let height: CGFloat = cell.buttonsCollectionView.collectionViewLayout.collectionViewContentSize.height вам высоту.

Кроме того, если вы используете горизонтальный UICollectionView, я предполагаю, что вам даже не нужно изменять эту строку, учитывая, что contentSize.height будет равен вашей самой большой высоте ячейки.

Это работает безупречно для меня, надеюсь, это поможет

РЕДАКТИРОВАТЬ

Просто подумав, вам может понадобиться разместить layoutIfNeeded() и invalidateLayout() здесь и там. Если это не сработает сразу, я скажу вам, где я их положил.

РЕДАКТИРОВАТЬ 2

Также забыл упомянуть, что я реализовал willDisplay и estimatedHeightForRowAt willDisplay.

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
}

Ответ 3

У меня была похожая проблема пару дней назад, когда я получил правильную высоту UICollectionView внутри UITableViewCell. Ни одно из решений не сработало. Я наконец нашел решение для изменения размера ячеек при повороте устройства

Короче говоря, я обновляю табличное представление с помощью функции:

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

Функция вызывается в viewWillAppear и viewWillTransitionToSize: withTransitionCoordinator

Работает как брелок даже для вращения устройства. Надеюсь это поможет.