UICollectionView weird layout - ios

I'm stuck with layout issue while displaying collection view cells. All ui is done programmatically. Here's what happening while scrolling down (ui elements appearing from top left corner):
Content of cell is being laid out on the fly, can't fix this. Content should be laid out before appearing. Any suggestions?
Code:
class CollectionView: UICollectionView {
// MARK: - Enums
enum Section {
case main
}
typealias Source = UICollectionViewDiffableDataSource<Section, Item>
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
private var source: Source!
private var dataItems: [Item] {...}
init(items: [Item]) {
super.init(frame: .zero, collectionViewLayout: .init())
collectionViewLayout = UICollectionViewCompositionalLayout { section, env -> NSCollectionLayoutSection? in
return NSCollectionLayoutSection.list(using: UICollectionLayoutListConfiguration(appearance: .plain), layoutEnvironment: env)
let cellRegistration = UICollectionView.CellRegistration<Cell, Item> { [unowned self] cell, indexPath, item in
cell.item = item
...modifications..
}
source = UICollectionViewDiffableDataSource<Section, Item>(collectionView: self) {
collectionView, indexPath, identifier -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
for: indexPath,
item: identifier)
}
}
func setDataSource(animatingDifferences: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(dataItems, toSection: .main)
source.apply(snapshot, animatingDifferences: animatingDifferences)
}
}
//Cell
class Cell: UICollectionViewListCell {
public weak var item: Item! {
didSet {
guard !item.isNil else { return }
updateUI()
}
}
private lazy var headerView: UIStackView = {...nested UI setup...}()
private lazy var middleView: UIStackView = {...nested UI setup...}()
private lazy var bottomView: UIStackView = {...nested UI setup...}()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
private func setupUI() {
let items = [
headerView,
middleView,
bottomView
]
items.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
contentView.addSubviews(items)
contentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
headerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
headerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
headerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
middleView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: padding),
middleView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
middleView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
bottomView.topAnchor.constraint(equalTo: middleView.bottomAnchor, constant: padding),
bottomView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
bottomView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
])
let constraint = bottomView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding)
constraint.priority = .defaultLow
constraint.isActive = true
}
#MainActor
func updateUI() {
func updateHeader() {
//...colors & other ui...
}
func updateMiddle() {
middleView.addArrangedSubview(titleLabel)
middleView.addArrangedSubview(descriptionLabel)
guard let constraint = titleLabel.getConstraint(identifier: "height"),
let constraint2 = descriptionLabel.getConstraint(identifier: "height")
else { return }
titleLabel.text = item.title
descriptionLabel.text = item.truncatedDescription
//Tried to force layout - didn't help
// middleView.setNeedsLayout()
//calc ptx height
constraint.constant = item.title.height(withConstrainedWidth: bounds.width, font: titleLabel.font)
//Media
if let media = item.media {
middleView.addArrangedSubview(imageContainer)
if let image = media.image {
imageView.image = image
} else if !media.imageURL.isNil {
guard let shimmer = imageContainer.getSubview(type: Shimmer.self) else { return }
shimmer.startShimmering()
Task { [weak self] in
guard let self = self else { return }
try await media.downloadImageAsync()
media.image.publisher
.sink {
self.imageView.image = $0
shimmer.stopShimmering()
}
.store(in: &self.subscriptions)
}
}
}
constraint2.constant = item.truncatedDescription.height(withConstrainedWidth: bounds.width, font: descriptionLabel.font)
// middleView.layoutIfNeeded()
}
func updateBottom() { //...colors & other ui... }
updateHeader()
updateMiddle()
updateBottom()
}
override func prepareForReuse() {
super.prepareForReuse()
//UI cleanup
middleView.removeArrangedSubview(titleLabel)
middleView.removeArrangedSubview(descriptionLabel)
middleView.removeArrangedSubview(imageContainer)
titleLabel.removeFromSuperview()
descriptionLabel.removeFromSuperview()
imageContainer.removeFromSuperview()
imageView.image = nil
}
}
Tried to force layout in UICollectionViewDelegate, it didn't help:
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
cell.setNeedsLayout()
cell.layoutIfNeeded()
}
Why is layout engine behaving so strange and how to fix it?

Hard to debug layout issues. But I don't believe you need to remove your views, like so:
override func prepareForReuse() {
super.prepareForReuse()
middleView.removeArrangedSubview(titleLabel)
middleView.removeArrangedSubview(descriptionLabel)
middleView.removeArrangedSubview(imageContainer)
titleLabel.removeFromSuperview()
descriptionLabel.removeFromSuperview()
imageContainer.removeFromSuperview()
That forced unnecessary layout steps, which might explain why your cell re-layout every time.
Instead just set the field's value to nill. Like you did with the image. When you call your function to updateUI() add the new values from that new item. You should not be updating your constraints here. Your contents' intrinsic content size will change and your cell should be able adapt if the constraints are defined correctly in the initial setupUI() method.
Auto layout constraints are linear equations, so when a variable changes they should be able to adapt once the layout engine recalculates the values.
It may take some time to get it right. You might have to play around with compression resistance and content hugging priorities to ensure the test field doesn't shrink, A good rule of thumb is to try and simplify your constraints as much as possible. Sorry cant be more specific as its hard to debug layout without a simulator.
Another point.
I doubt this is the root cause here. But a potential optimisation to keep in mind.
You seem to be using your model - Item - (which conforms to hashable) as the ItemIdentifierType of the diffable data source.
UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>
This is indeed how many tutorials show it, yet it is no longer the recommended way. Using a hash of the entire object results in the cell being destroyed and added every time a field changes.
This is described in the docs
And has been explained in WWDC 21
The optimal approach is to pass the model / view model into your cell and update the layout it if some the model properties change. Kind of like what you are doing already. Combine is handy for this. This way cells are only destroyed when an entirely new model is added or removed from the data source.

Related

Table view inside a cell of table view, however its frame height is 0

I encountered with a problem when was trying to build a table view inside a cell of another table view. I thought it’s pretty straightforward task...
Structure of app:
Collection view with workout items.
After tapping on each, user will be moved to static table view, where problem’s occurred.
I noticed in Reveal app that table view appears in the cell and it doesn’t shout that constraints are not correct, everything seems fine. But table view has height of its frame 0.333.
Reveal app screenshot and the fact that table view exists
But user would see this:
I tried methods(with different values) as tough: estimatedHeightForRowAt, heightForRowAt, but they do nothing, by the way UITableView.automaticDimension returns -1.
But when I set explicitly height for the row for outermost table view everything is good, except the fact that the size will be different in distinct elements, and table view is too big, or too small. I made this constraint, but it seems work only for one case when element has 3 splits:
self.heightAnchor.constraint(equalToConstant: 40 + titleLabel.frame.height + CGFloat(splits.count) * 44.0)
With 3 splits in element
Less than 3
I read that adding subviews of custom cell to contentView instead of cell itself helped somebody, but in my case I got this message:
Warning once only: Detected a case where constraints ambiguously suggest a height of zero for a table view cell's content view. We're considering the collapse unintentional and using standard height instead. Cell: <projectClimber.SplitCell: 0x140670050; baseClass = UITableViewCell; frame = (0 0; 355 44); autoresize = W; layer = <CALayer: 0x600003797700>>
I was able to dispose of this warning actually by removing contentView and just add sub view to cell itself. But it doesn’t solve anything.
I don’t really know how to set proper value to table view's height, maybe there’s an approach without using table view or maybe in different way. I want to hear your opinion about this, thank you.
Code
Main table view
class WorkoutStatisticsTableViewController: UITableViewController {
....
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//...
//Switch statement and other cases
//...
case 3:
let cell = FourthTableViewCell()
cell.configure(with: workout)
cell.selectionStyle = .none
// Here I added
return cell
}
//I tried these methods, but nothing changed
// override func tableView(_ tableView: UITableView, estimatedHeightForRowAt
indexPath: IndexPath) -> CGFloat {
// return 250
// }
//
// override func tableView(_ tableView: UITableView, heightForRowAt indexPath:
IndexPath) -> CGFloat {
//
// return UITableView.automaticDimension
// }
....
}
Table view cell class, where lay title label and table view
class FourthTableViewCell: UITableViewCell, UITableViewDelegate, UITableViewDataSource {
//...
//Methods for tableview delegate and data source and implementation of title label
//...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) ->
UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: SplitCell.reuseIdentifier, for: indexPath) as! SplitCell
cell.configure(cellWithNumber: indexPath.row + 1, with: splits[indexPath.row])
cell.selectionStyle = .none
return cell
}
//Number of splits, i.e. rows in table view in this cell
var splits: [Int] = []
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
self.tableView.dataSource = self
self.tableView.delegate = self
//just for making table view visible
self.tableView.backgroundColor = .orange
tableView.register(SplitCell.self, forCellReuseIdentifier: SplitCell.reuseIdentifier)
addSubview(titleLabel)
addSubview(tableView)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
tableView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
tableView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
tableView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
tableView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
// Tried this line for calculating height of cell
// self.heightAnchor.constraint(equalToConstant: 40 + titleLabel.frame.height + CGFloat(splits.count) * 44.0)
])
}
//Configure cell with workout element, which is used for taking splits
func configure(with workout: Workout) {
print("Configure func, workout's splits \(workout.splits.count)")
//Need to populate splits this way, because in workout I use List<Int>
for item in workout.splits {
self.splits.append(item)
}
}
}
Split cell class. Where is located only two labels
class SplitCell: UITableViewCell {
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
addSubview(titleLabel)
addSubview(timeLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
timeLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10),
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 15),
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
timeLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10),
timeLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 50),
timeLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10)
])
}
func configure(cellWithNumber number: Int, with time: Int) {
titleLabel.text = "Split #\(number)"
timeLabel.text = String.makeTimeString(seconds: time)
}
}
After dozen of tries I decided to use this constraint for table view, that is located inside a cell of static table view:
tableView.heightAnchor.constraint(equalToConstant: CGFloat(splits.count) * 27)
But there's still a problem - magic value. 27 is a sum of 17, which is a height of label and 10 is a sum of constant of bottom and top constraints of label in an inner table view's cell.
I'm convinced there's a better solution somewhere, but for now it's better than nothing.

getting subview height to estimate another view height

Hi i want to get the subviews height to estimate the detailUIView heightAnchor. In the detailUIView i have 4 labels that layedout using auto constraints(programmatically) and i want to get the views height so i can estimate the detailUIView.In the function estimateCardHeight i am iterating all the subviews and trying to get the height of each subview and adding them but i am getting 0.0. Whats am i missing here i couldn't figure it out.
class ComicDetailViewController: UIViewController {
var comicNumber = Int()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationItem.title = "Comic Number: \(comicNumber)"
}
let detailUIView = DetailComicUIView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
// Do any additional setup after loading the view.
detailUIView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(detailUIView)
addConstraints()
print(estimateCardHeight())
}
public func configure(with viewModel: XKCDTableViewCellVM){
detailUIView.configure(with: viewModel)
comicNumber = viewModel.number
}
private func estimateCardHeight() -> CGFloat{
var allHeight : CGFloat = 0
for view in detailUIView.subviews{
allHeight += view.frame.origin.y
}
return allHeight
}
private func addConstraints(){
var constraints = [NSLayoutConstraint]()
constraints.append(detailUIView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 10))
constraints.append(detailUIView.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 20))
constraints.append(detailUIView.rightAnchor.constraint(equalTo: self.view.rightAnchor, constant: -20))
constraints.append(detailUIView.heightAnchor.constraint(equalToConstant: self.view.frame.height/3))
NSLayoutConstraint.activate(constraints)
}
}
you are missing view.layoutIfNeeded()
addConstraints()
view.layoutIfNeeded()
print(estimateCardHeight())
as apple's saying
Use this method to force the view to update its layout immediately. When using Auto Layout, the layout engine updates the position of views as needed to satisfy changes in constraints.
Lays out the subviews immediately, if layout updates are pending.
And I guess:
the way to estimate height,
private func estimateCardHeight() -> CGFloat{
var allHeight : CGFloat = 0
for view in detailUIView.subviews{
allHeight += view.frame.size.height
// allHeight += view.frame.origin.y
}
return allHeight
}
An other way to achieve it.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
print(estimateCardHeight())
}
viewDidLayoutSubviews()
Called to notify the view controller that its view has just laid out its subviews.

UICollectionView not displayed to view

I have been stuck on this problem for nearly a week now. I have even reverted my code and wrote the same code as some tutorials but I cannot seem to get my UICollectionView to be displayed to the view for the life of me. I have a lot of other code going on in my view controllers so I am going to display the code that pertains to the UICollectionView. This is what I have so far:
view controller:
class UARTModuleViewController: UIViewController, CBPeripheralManagerDelegate, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
override func viewDidLoad() {
super.viewDidLoad()
setupMenuBar()
}
let menuBar: MenuBar = {
let mb = MenuBar()
return mb
}()
private func setupMenuBar() {
view.addSubview(menuBar)
let horizontalConstraint = NSLayoutConstraint.constraints(withVisualFormat: "H:|[v0]|", options: NSLayoutFormatOptions(), metrics: nil, views: ["v0":menuBar])
let verticalConstraint = NSLayoutConstraint.constraints(withVisualFormat: "V:|[v0(100)]|", options: NSLayoutFormatOptions(), metrics: nil, views: ["v0":menuBar])
view.addConstraints(horizontalConstraint)
view.addConstraints(verticalConstraint)
}
}
MenuBar.swift:
class MenuBar : UIView, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
lazy var collectionView : UICollectionView = {
let layout = UICollectionViewFlowLayout()
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.backgroundColor = UIColor.red
cv.dataSource = self
cv.delegate = self
return cv
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(collectionView)
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "MenuCell")
//took out constraints here just to make the warning mentioned below easier to follow.
}
I do have my protocols implemented but I didn't add them just to save the amount of code I'm posting. I do get this warning after the UARTModuleViewController loads :
[LayoutConstraints] Unable to simultaneously satisfy constraints.
I've tried reasoning through it and can't seem to come to a solution. I do have a cell class as well but thought it was not necessary to include since I can't get the UICollectionView to be displayed. The only other thing that I may add is that I have a UIImageView in the middle of the view that I added in story board and put constraints on like so:
Does anyone have any suggestions as to what I may be doing wrong? Thanks in advance for any help or advice that can be given.
According to comments:
First of all, every time you use constraint in code, you must set translatesAutoresizingMaskIntoConstraints = false to the view you want to add constraint.
You're not telling the collection view to fill entire MenuBar space.
Constraints applied to MenuBar in setupMenuBar are not enaugh to determine the size and position of the view.
These are changes you should apply to tell the MenuBar to fill width, be 60 pt height and to position on bottom of the screen:
private func setupMenuBar() {
view.addSubview(menuBar)
menuBar.translatesAutoresizingMaskIntoConstraints = false // this will make your constraint working
menuBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true // every constraint must be enabled.
menuBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
menuBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
menuBar.heightAnchor.constraint(equalToConstant: 60).isActive = true
}
To tell the collection view to fill entire MenuBar view do this in MenuBar init(frame:) method:
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(collectionView)
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "MenuCell")
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.topAnchor.constraint(equalTo: topAnchor).isActive = true
collectionView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
collectionView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
collectionView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
//took out constraints here just to make the warning mentioned below easier to follow.
}
As you can see in my code, I usually use "anchor" methods to apply constraint because are easier than visual format.

How to turn off adjusting large titles by UITableView in iOS 11?

There's this large titles feature in iOS 11 that shows large title when the UITableViewController's table is scrolled to top, and gets collapsed to standard small title when the user scrolls the table away from top. This is standard behavior. I need the navigation controller to behave a bit differently - I need to always show the large title. How to achieve this?
Following code does not help, it still collapses when scrolled.
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.largeTitleDisplayMode = .always
I've achieved it unintentionally when embedded UITableViewController inside UIViewController.
I'm not sure whether it is an Apple's bug or intended behavior.
So stack is as simple as UINavigationController -> UIViewController(used as container) -> UITableViewController
Here is sample of view controller with embedded UITableViewController fullscreen
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
var vc = UITableViewController(style: .plain)
var array: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
vc.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(vc.view)
view.addConstraint(view.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor))
view.addConstraint(view.rightAnchor.constraint(equalTo: vc.view.rightAnchor))
view.addConstraint(view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: vc.view.topAnchor))
view.addConstraint(view.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor))
vc.tableView.delegate = self
vc.tableView.dataSource = self
array = "0123456789".characters.map(String.init)
vc.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "identifier")
title = "Title"
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return array.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "identifier", for: indexPath)
cell.textLabel?.text = array[indexPath.row]
return cell
}
}
Here is the result
Hope it helps.
P.S. Surprisingly, my current problem is that I don't know how to get collapsing behavior with such architecture :)
What I did was to add another view between navigationBar and TableView with a height of 1.
let tableViewSeperator: UIView = {
let view = UIView()
// remove the color, so it wont be visible.
view.backgroundColor = UIColor.systemBlue
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
One thing which is important is add this seperator view as a subview of your viewcontroller's view before tableView, otherwise it won't work
view.addSubview(tableViewSeperator)
view.addSubview(tableView)
or if you want to save one line of code, you can also do it like this.
[tableViewSeperator, tableView].forEach({view.addSubview($0)})
Then set its constraints like this.
tableViewSeperator.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true
tableViewSeperator.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0).isActive = true
tableViewSeperator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 0).isActive = true
tableViewSeperator.heightAnchor.constraint(equalToConstant: 1).isActive = true
The last thing is change the tableView TopAnchor to be the BottomAnchor of sperator View.
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0).isActive = true
tableView.topAnchor.constraint(equalTo: tableViewSeperator.bottomAnchor, constant: 0).isActive = true
tableView.bottomAnchor.constraint(equalTo: createItemBtn.topAnchor, constant: 0).isActive = true
Now when you scroll the the NavigationBar will stay as Large.
You need to add UIView(it's can be width=0, height=0) before add UITableView.
example
Then this code will work
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.largeTitleDisplayMode = .always

UITableView with UITableViewCells + AutoLayout - Not As Smooth As It *Should* Be

I recently posted a question about a UITableView with custom UITableCells that was not smooth when the cell's subviews were positioned with AutoLayout. I got some comments suggesting the lack of smoothness was due to the complex layout of the cells. While I agree that the more complex the cell layout, the more calculation the tableView has to do to get the cell's height, I don't think 10-12 UIView and UILabel subviews should cause the amount of lag I was seeing as I scrolled on an iPad.
So to prove my point further, I created a single UIViewController project with a single UITableView subview and custom UITableViewCells with only 2 labels inside of their subclass. And the scrolling is still not perfectly smooth. From my perspective, this is the most basic you can get - so if a UITableView is still not performant with this design, I must be missing something.
The estimatedRowHeight of 110 used below is a very close estimate to the actual row height average. When I used the 'User Interface Inspector' and looked at the heights of each cell, one by one, they ranged from 103 - 124.
Keep in mind, when I switch the code below to not use an estimatedRowHeight and UITableViewAutomaticDimension and instead implement func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {, calculating the height with frame values, the UITableView scrolls like butter.
Screenshot of App (for reference)
Source code of the App (where the scrolling is not perfectly smooth)
// The custom `Quote` object that holds the
// properties for our data mdoel
class Quote {
var text: String!
var author: String!
init(text: String, author: String) {
self.text = text
self.author = author
}
}
// Custom UITableView Cell, using AutoLayout to
// position both a "labelText" (the quote itself)
// and "labelAuthor" (the author's name) label
class CellQuote: UITableViewCell {
private var containerView: UIView!
private var labelText: UILabel!
private var labelAuthor: UILabel!
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.backgroundColor = UIColor.whiteColor()
containerView = UIView()
containerView.backgroundColor = UIColor(
red: 237/255,
green: 237/255,
blue: 237/255,
alpha: 1.0
)
contentView.addSubview(containerView)
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.leadingAnchor.constraintEqualToAnchor(contentView.leadingAnchor, constant: 0).active = true
containerView.trailingAnchor.constraintEqualToAnchor(contentView.trailingAnchor, constant: 0).active = true
containerView.topAnchor.constraintEqualToAnchor(contentView.topAnchor, constant: 4).active = true
containerView.bottomAnchor.constraintEqualToAnchor(contentView.bottomAnchor, constant: 0).active = true
labelText = UILabel()
labelText.numberOfLines = 0
labelText.font = UIFont.systemFontOfSize(18)
labelText.textColor = UIColor.darkGrayColor()
containerView.addSubview(labelText)
labelText.translatesAutoresizingMaskIntoConstraints = false
labelText.leadingAnchor.constraintEqualToAnchor(containerView.leadingAnchor, constant: 20).active = true
labelText.topAnchor.constraintEqualToAnchor(containerView.topAnchor, constant: 20).active = true
labelText.trailingAnchor.constraintEqualToAnchor(containerView.trailingAnchor, constant: -20).active = true
labelAuthor = UILabel()
labelAuthor.numberOfLines = 0
labelAuthor.font = UIFont.boldSystemFontOfSize(18)
labelAuthor.textColor = UIColor.blackColor()
containerView.addSubview(labelAuthor)
labelAuthor.translatesAutoresizingMaskIntoConstraints = false
labelAuthor.topAnchor.constraintEqualToAnchor(labelText.bottomAnchor, constant: 20).active = true
labelAuthor.leadingAnchor.constraintEqualToAnchor(containerView.leadingAnchor, constant: 20).active = true
labelAuthor.trailingAnchor.constraintEqualToAnchor(containerView.trailingAnchor, constant: -20).active = true
labelAuthor.bottomAnchor.constraintEqualToAnchor(containerView.bottomAnchor, constant: -20).active = true
self.selectionStyle = UITableViewCellSelectionStyle.None
}
func configureWithData(quote: Quote) {
labelText.text = quote.text
labelAuthor.text = quote.author
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// The UIViewController that is a
class ViewController: UIViewController, UITableViewDataSource {
var tableView: UITableView!
var dataItems: [Quote]!
override func viewDidLoad() {
super.viewDidLoad()
tableView = UITableView()
tableView.dataSource = self
tableView.registerClass(CellQuote.self, forCellReuseIdentifier: "cellQuoteId")
tableView.backgroundColor = UIColor.whiteColor()
tableView.separatorStyle = UITableViewCellSeparatorStyle.None
tableView.estimatedRowHeight = 110
tableView.rowHeight = UITableViewAutomaticDimension
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor).active = true
tableView.topAnchor.constraintEqualToAnchor(view.topAnchor, constant: 20).active = true
tableView.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor).active = true
tableView.bottomAnchor.constraintEqualToAnchor(view.bottomAnchor).active = true
dataItems = [
Quote(text: "One kernel is felt in a hogshead; one drop of water helps to swell the ocean; a spark of fire helps to give light to the world. None are too small, too feeble, too poor to be of service. Think of this and act.", author: "Michael.Frederick"),
Quote(text: "A timid person is frightened before a danger, a coward during the time, and a courageous person afterward.", author: "Lorem.Approbantibus."),
Quote(text: "There is only one way to defeat the enemy, and that is to write as well as one can. The best argument is an undeniably good book.", author: "Lorem.Fruitur."),
// ... many more quotes ...
]
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
// MARK: - UITableViewDataSource
func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataItems.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("cellQuoteId") as! CellQuote
cell.configureWithData(dataItems[indexPath.row])
return cell
}
}
I like matt's suggestion below, but am still trying to tweak it to work for me:
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
var cellHeights: [CGFloat] = [CGFloat]()
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
if cellHeights.count == 0 {
var cellHeights = [CGFloat]()
let numQuotes: Int = dataItems.count
for index in 0...numQuotes - 1 {
let cell = CellQuote()
let quote = dataItems[index]
cell.configureWithData(quote)
let size = cell.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
cellHeights.append(size.height)
}
self.cellHeights = cellHeights
}
return self.cellHeights[indexPath.row]
}
}
I've never found the automatic row height mechanism to be as smooth as the old calculated layout techniques that we used to use before auto layout came along. The bottleneck, as you can readily see by using Instruments, is that the runtime must call systemLayoutSizeFittingSize: on every new cell as it scrolls into view.
In my book, I demonstrate my preferred technique, which is to calculate the heights for all the cells once when the table view first appears. This means that I can supply the answer to heightForRowAtIndexPath instantly from then on, making for the best possible user experience. Moreover, if you then replace your call to dequeueReusableCellWithIdentifier with the much better and more modern dequeueReusableCellWithIdentifier:forIndexPath, you have the advantage that the cell comes to you with its correct size and no further layout is needed after that point.
the clear background from your two text labels is causing the performance issues. add these lines and you should see a performance increase:
labelText.backgroundColor = containerView.backgroundColor
labelAuthor.backgroundColor = containerView.backgroundColor
a good way to check any other potential issues is by turning on 'Color Blended Layers' in the iOS Simulator's 'Debug' menu option
UPDATE
usually what i do for dynamic cell heights is create a prototype cell and use it for sizing. here is what you'd do in your case:
class CellQuote: UITableViewCell {
private static let prototype: CellQuote = {
let cell = CellQuote(style: .Default, reuseIdentifier: nil)
cell.contentView.translatesAutoresizingMaskIntoConstraints = false
return cell
}()
static func heightForQuote(quote: Quote, tableView:UITableView) -> CGFloat {
prototype.configureWithData(quote)
prototype.labelText.preferredMaxLayoutWidth = CGRectGetWidth(tableView.frame)-40
prototype.labelAuthor.preferredMaxLayoutWidth = CGRectGetWidth(tableView.frame)-40
prototype.layoutIfNeeded();
return CGRectGetHeight(prototype.contentView.frame)
}
// existing code here
}
in your viewDidLoad remove the rowHeight and estimatedRowHeight lines and replace with becoming the delegate
class ViewController {
override func viewDidLoad() {
// existing code
self.tableView.delegate = self
// existing code
}
// get prototype cell height
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
let quote = dataItems[indexPath.row]
return CellQuote.heightForQuote(quote, tableView: tableView)
}

Resources