Good?
I have a problem with the implementation of a UICollectionview intro a UITableViewCell, because the height calculation of this component does not work properly, even though using updateConstraints()
and called by delegate tableView.beginUpdates()
, sometimes works correctly and sometimes renders missing height.
My layout is a table with a lot of cells, these cells have some texts and items in two collectionviews (I'm using UICollectionView because the size of these items needs to be dynamically)
The implementation of the UITableViewCell:
protocol CardTableViewCellDelegate: AnyObject {
func updateTableView()
}
class CardTableViewCell: UITableViewCell {
static let identifier: String = "CardTableViewCell"
lazy var cardView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 8
view.layer.masksToBounds = true
view.clipsToBounds = true
view.layer.borderWidth = 1
view.layer.borderColor = UIColor.separator.cgColor
return view
}()
lazy var containerStackView: UIStackView = {
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 8
return stackView
}()
lazy var title: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .headline)
label.numberOfLines = 0
return label
}()
lazy var subtitle: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .subheadline)
label.numberOfLines = 0
return label
}()
lazy var sourceNew: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .footnote)
label.numberOfLines = 0
return label
}()
var assetsCollectionView: AssetsCollectionView = {
let collectionView = AssetsCollectionView()
collectionView.translatesAutoresizingMaskIntoConstraints = false
return collectionView
}()
lazy var benchmarksCollectionView: BenchmarksCollectionView = {
let collectionView = BenchmarksCollectionView()
collectionView.translatesAutoresizingMaskIntoConstraints = false
return collectionView
}()
weak var delegate: CardTableViewCellDelegate?
// MARK: - Init
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Methods
func configure(with card: Card) {
title.text = card.title
subtitle.text = card.subtitle
sourceNew.text = card.sourceNew
configureAssetsCollectionView(with: card.assets)
configurebenchmarksCollectionView(with: card.benchmarks)
}
private func configureAssetsCollectionView(with model: [Asset]?) {
if let assets = model {
assetsCollectionView.configure(with: assets)
containerStackView.addArrangedSubview(assetsCollectionView)
assetsCollectionView.updateConstraints()
delegate?.updateTableView()
}
}
private func configurebenchmarksCollectionView(with model: [Benchmark]?) {
if let benchmarks = model {
benchmarksCollectionView.configure(with: benchmarks)
containerStackView.addArrangedSubview(benchmarksCollectionView)
}
}
}
// MARK: - ViewCode
extension CardTableViewCell {
func setupView() {
setupLayout()
setupHierarchy()
setupConstrains()
}
func setupLayout() {
backgroundColor = .white
cardView.backgroundColor = .clear
}
func setupHierarchy() {
[title,
subtitle,
sourceNew].forEach(containerStackView.addArrangedSubview(_:))
cardView.addSubview(containerStackView)
contentView.addSubview(cardView)
}
func setupConstrains() {
NSLayoutConstraint.activate([
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16),
containerStackView.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 8),
containerStackView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 8),
containerStackView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -8),
containerStackView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -8),
assetsCollectionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 22),
benchmarksCollectionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 22)
])
}
}
and, the UICollectionView:
import UIKit
class AssetsCollectionView: UICollectionView {
private var assets: [Asset] = []
override var intrinsicContentSize: CGSize {
self.layoutIfNeeded()
print(contentSize)
return self.contentSize
}
init() {
let layout = LeftAlignedCollectionViewFlowLayout()
layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
layout.minimumLineSpacing = 8
layout.minimumInteritemSpacing = 8
super.init(frame: .zero, collectionViewLayout: layout)
delegate = self
dataSource = self
register(AssetCollectionViewCell.self,
forCellWithReuseIdentifier: AssetCollectionViewCell.identifier)
showsHorizontalScrollIndicator = false
showsVerticalScrollIndicator = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(with assets: [Asset]) {
self.assets = assets
reloadData()
}
}
extension AssetsCollectionView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return assets.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView
.dequeueReusableCell(withReuseIdentifier: AssetCollectionViewCell.identifier, for: indexPath) as? AssetCollectionViewCell else {
return UICollectionViewCell()
}
cell.configure(with: assets[indexPath.row])
return cell
}
}
So, can this component calculate your height automatically without using height calculation tricks? I tried using a lot of methods to send the cell to recalculate your constraints, but no one worked. :(
I know that I can use the number of items to calculate the height, but I need more powerful code to handle possible causes... any anyone help me with this?
The github url to reproduce locally: https://github.com/ramonfsk/MarketContextTimeline.git
You'll run into a number of issues trying to use a "self-sizing" collection view.
Collection views are designed to layout the cells based on the frame - not the other way around.
If we look at your LeftAlignedCollectionViewFlowLayout
(this is what happens whether using a subclassed layout or not):
`layoutAttributesForElements(in rect: CGRect)`
is called.
If we start the layout with:
assetsCollectionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 22)
the rect
in layoutAttributesForElements(in rect: CGRect)
will be:
(0.0, 0.0, 353.0, 22.0) // the width will vary
Let's say we're using these strings:
"MGLU3", "IBVV11", "BBSA3", "Slightly Longer String",
"FIVE", "SIX", "SEVEN", "EIGHT",
"NINE", "TEN", "ELEVEN", "TWELVE",
"Hash Index Ethereum position replicated with a 100% accuracy",
"FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN",
and the goal is this:
The layout starts starts looking like this:
Let's add a red rectangle showing the initial frame:
and a green bracket showing the current collectionView.contentSize.height
:
So, let's expand the collection view frame to show all the cells:
Note that the contentSize.height
does not reflect all the cells... and it can vary wildly depending on the actual cell sizes, initial frame, etc. We don't get a valid (for our purposes) contentSize.height
until all of the cells have been laid-out by the collection view.
Even if we jump through a bunch of hoops to "force" all of the cells to be rendered, we still have timing issues... the collection view can't layout the cells until it knows its width, and since it's embedded in a table view cell we have to make "call-backs" to auto-size the cell heights.
In addition -- let's use your code but explicitly set the height of the collection view to show all the cells:
Looks good -- except cells are reused... so let's scroll up and down a bit:
Yeah, clearly not acceptable.
So... I'd suggest using a UIView
subclass that lays-out the labels on its own.
The general logic is:
.systemLayoutSizeFitting(...)
to get the size of the "item"We'll start with a "padded label view" that is roughly equivalent to a cell:
class PaddedLabelView: UIView {
let theLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
theLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(theLabel)
let edgeConstraints: [NSLayoutConstraint] = [
theLabel.topAnchor.constraint(equalTo: topAnchor, constant: 2.0),
theLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
theLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
theLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2.0),
]
// this prevents auto-layout complaints
edgeConstraints[2].priority = .required - 1
edgeConstraints[3].priority = .required - 1
NSLayoutConstraint.activate(edgeConstraints)
// properties
theLabel.numberOfLines = 0
theLabel.textColor = .white
theLabel.font = .systemFont(ofSize: 12.0, weight: .bold)
backgroundColor = .systemBlue
layer.cornerRadius = 6.0
layer.masksToBounds = true
}
}
and now we create an "arranged views" class:
class BasicArrangedViewsView: UIView {
public var theStrings: [String] = [] {
didSet {
// remove existing views
for v in labelViews { v.removeFromSuperview() }
labelViews = []
for str in theStrings {
let t = PaddedLabelView()
t.theLabel.text = str
addSubview(t)
labelViews.append(t)
}
calcFrames(bounds.width)
}
}
// horizontal space between label views
let interItemSpace: CGFloat = 8.0
// vertical space between "rows"
let lineSpace: CGFloat = 8.0
// we use these to set the intrinsic content size
private var myHeight: CGFloat = 0.0
private var myWidth: CGFloat = 0.0
private var myHC: NSLayoutConstraint!
private var labelViews: [PaddedLabelView] = []
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// initialize height constraint, but don't activate it yet
myHC = heightAnchor.constraint(equalToConstant: 0.0)
myHC.priority = .required - 1
}
func calcFrames(_ tagetWidth: CGFloat) {
// this can be called multiple times, and
// may be called before we have a frame
if tagetWidth == 0.0 {
return
}
var newWidth: CGFloat = 0.0
var newHeight: CGFloat = 0.0
var x: CGFloat = 0.0
var y: CGFloat = 0.0
var isMultiLine: Bool = false
var thisRect: CGRect = .zero
for thisView in labelViews {
// start with NOT needing to wrap
isMultiLine = false
// set the frame width to a very wide value, so we get the non-wrapped size
thisView.frame.size.width = 5000
thisView.layoutIfNeeded()
var sz: CGSize = thisView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
sz.width = ceil(sz.width)
sz.height = ceil(sz.height)
thisRect = .init(x: x, y: y, width: sz.width, height: sz.height)
// if this item is too wide to fit on the "row"
if thisRect.maxX > tagetWidth {
// if this is not the FIRST item on the row
// move down a row and reset x
if x > 0.0 {
x = 0.0
y = thisRect.maxY + lineSpace
}
thisRect = .init(x: x, y: y, width: sz.width, height: sz.height)
// if this item is still too wide to fit, that means
// it needs to wrap the text
if thisRect.maxX > tagetWidth {
isMultiLine = true
// this will give us the height based on max available width
sz = thisView.systemLayoutSizeFitting(.init(width: tagetWidth, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
sz.width = ceil(sz.width)
sz.height = ceil(sz.height)
// update the frame
thisView.frame.size = sz
thisView.layoutIfNeeded()
// this will give us the width needed for the wrapped text (instead of the max available width)
sz = thisView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
sz.width = ceil(sz.width)
sz.height = ceil(sz.height)
thisRect = .init(x: x, y: y, width: sz.width, height: sz.height)
}
}
// if we needed to wrap the text, adjust the next Y and reset X
if isMultiLine {
x = 0.0
y = thisRect.maxY + lineSpace
}
thisView.frame = thisRect
// update the max width var
newWidth = max(newWidth, thisRect.maxX)
// if we did NOT need to wrap lines, adjust the X
if !isMultiLine {
x += sz.width + interItemSpace
}
}
newHeight = thisRect.maxY
if myWidth != newWidth || myHeight != newHeight {
myWidth = newWidth
myHeight = newHeight
// don't activate the constraint if we're not in an auto-layout case
if self.translatesAutoresizingMaskIntoConstraints == false {
myHC.isActive = true
}
// update the height constraint constant
myHC.constant = myHeight
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
return .init(width: myWidth, height: myHeight)
}
override func invalidateIntrinsicContentSize() {
super.invalidateIntrinsicContentSize()
// walk-up the view hierarchy...
// this will handle self-sizing cells in a table or collection view, without
// the need to "call back" to the controller
var sv = superview
while sv != nil {
if sv is UITableViewCell || sv is UICollectionViewCell {
sv?.invalidateIntrinsicContentSize()
sv = nil
} else {
sv = sv?.superview
}
}
}
override var bounds: CGRect {
willSet {
if newValue.width != bounds.width {
calcFrames(newValue.width)
}
}
}
}
and a sample controller:
class BasicVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
// sample strings
let strs: [String] = [
"MGLU3", "IBVV11", "BBSA3", "Slightly Longer String",
"FIVE", "SIX", "SEVEN", "EIGHT",
"NINE", "TEN", "ELEVEN", "TWELVE",
"Hash Index Ethereum position replicated with a 100% accuracy",
"FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN",
]
let aView = BasicArrangedViewsView()
aView.theStrings = strs
aView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(aView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
aView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
aView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
aView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// don't set a bottom or height constraint
])
// so we can see the view frame
aView.backgroundColor = .systemYellow
}
}
The result:
and how it looks implemented in your project: