How to get a correct automatic row height calculation when modifying the layout inside a UITableViewCell's layoutSubviews() method? - ios

I have a UITableView with cells that are variable in height, laid out with Auto Layout in Interface Builder (rowHeight = UITableViewAutomaticDimension). When the user rotates the device I perform some manual layout changes inside the cell that affect the intrinsicContentSize one of the cell's subviews. As these changes are dependent on the initial frame of that particular subview I can only apply these changes once the layout engine has resolved the constraints and applied the correct frames to all subviews for the new (rotated) layout. This happens inside the layoutSubviews() method. So to apply my manual changes I override that method and first call the super implementation.
override func layoutSubviews() {
// Call `super` implementation to let the layout engine do its job
// and apply the new frames to subviews after resolving constraints.
super.layoutSubviews()
// 🔄 Apply manual layout changes here
}
Now the problem is that my manual layout changes possibly also change the cell's height. Hence I need a way to inform the table view that a second layout pass is required for this cell before the (automatic) height calculation returns. (Otherwise the table view will just use the row height it computed before the manual layout changes.)
Usually you do this by invalidating the layout with a setNeedsLayout() call on the view that needs another layout pass. However, I cannot do this from inside the layoutSubviews() method because it would result in an infinite loop of layout passes and crash the app.
So I tried to figure out a way if the layout had changed in this particular call of layoutSubviews() and came up with this approach:
override func layoutSubviews() {
// Get old image view size before layout pass
let previousImageSize = imageView.frame.size
// Run a layout pass
super.layoutSubviews()
// Get the new image view size
let newImageSize = imageView.frame.size
// Only trigger a new layout pass if the size changed
if newImageSize != previousImageSize {
// 🔄 Apply manual layout changes here
// Trigger another layout pass
setNeedsLayout()
}
}
(In this example, the imageView is a subview of the cell on which my layout depends.)
When running this code I realized that the code inside the if branch is never executed, i.e. the imageView size never changes inside the layoutSubviews() method. But it does change - only at a different time.
That's inconsistent to my understanding Auto Layout in general:
A view is always responsible for laying out its subviews, i.e. for setting their frames appropriately and this is done in layoutSubviews().
So here are my questions:
Why does the system change the image view's size from outside the layoutSubviews() method?
and more importantly:
How can I get the table view to run a second layout pass when computing the row height for a cell?

Related

A mystery about iOS autolayout with table views and self-sizing table view cells

To help in following this question, I've put up a GitHub repository:
https://github.com/mattneub/SelfSizingCells/tree/master
The goal is to get self-sizing cells in a table view, based on a custom view that draws its own text rather than a UILabel. I can do it, but it involves a weird layout kludge and I don't understand why it is needed. Something seems to be wrong with the timing, but then I don't understand why the same problem doesn't occur for a UILabel.
To demonstrate, I've divided the example into three scenes.
Scene 1: UILabel
In the first scene, each cell contains a UILabel pinned to all four sides of the content view. We ask for self-sizing cells and we get them. Looks great.
Scene 2: StringDrawer
In the second scene, the UILabel has been replaced by a custom view called StringDrawer that draws its own text. It is pinned to all four sides of the content view, just like the label was. We ask for self-sizing cells, but how will we get them?
To solve the problem, I've given StringDrawer an intrinsicContentSize based on the string it is displaying. Basically, we measure the string and return the resulting size. In particular, the height will be the minimal height that this view needs to have in order to display the string in full at this view's current width, and the cell is to be sized to that.
class StringDrawer: UIView {
#NSCopying var attributedText = NSAttributedString() {
didSet {
self.setNeedsDisplay()
self.invalidateIntrinsicContentSize()
}
}
override func draw(_ rect: CGRect) {
self.attributedText.draw(with: rect, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin], context: nil)
}
override var intrinsicContentSize: CGSize {
let measuredSize = self.attributedText.boundingRect(
with: CGSize(width:self.bounds.width, height:10000),
options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin],
context: nil).size
return CGSize(width: UIView.noIntrinsicMetric, height: measuredSize.height.rounded(.up) + 5)
}
}
But something's wrong. In this scene, some of the initial cells have some extra white space at the bottom. Moreover, if you scroll those cells out of view and then back into view, they look correct. And all the other cells look fine. That proves that what I'm doing is correct, so why isn't it working for the initial cells?
Well, I've done some heavy logging, and I've discovered that at the time intrinsicContentSize is called initially for the visible cells, the StringDrawer does not yet correctly know its own final width, the width that it will have after autolayout. We are being called too soon. The width we are using is too narrow, so the height we are returning is too tall.
Scene 3: StringDrawer with workaround
In the third scene, I've added a workaround for the problem we discovered in the second scene. It works great! But it's horribly kludgy. Basically, in the view controller, I wait until the view hierarchy has been assembled, and then I force the table view to do another round of layout by calling beginUpdates and endUpdates.
var didInitialLayout = false
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if !didInitialLayout {
didInitialLayout = true
UIView.performWithoutAnimation {
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
}
}
The Mystery
Okay, so here are my questions:
(1) Is there a better, less kludgy workaround?
(2) Why do we need this workaround at all? In particular, why do we have this problem with my StringDrawer but not with a UILabel? Clearly, a UIlabel does know its own width early enough for it to give its own content size correctly on the first pass when it is interrogated by the layout system. Why is my StringDrawer different from that? Why does it need this extra layout pass?

Subview's bounds are zero in layoutSubviews()

The bounds of a subview of a subview of a custom UIView seem to be 0 in layoutSubviews(), hence using the bounds in layoutSubviews() is a problem.
To show the problem I put a demo on GitHub: SOBoundsAreZero
Here's a direct link to the custom view's implementation: DemoView.swift
The structure of the custom view "DemoView" is like this:
DemoView
firstLevelSubview
secondLevelSubview
This structure is being created programmatically using Auto Layout.
When layoutSubviews() is called, the view and the firstLevelSubview have the expected bounds, but the secondLevelSubview's bounds are 0.
I expected all subviews using Auto Layout to have the correct bounds, at least in the last call to layoutSubviews.
The structure is an abstraction of a real case. To avoid the problem, secondLevelSubview could be added as a first level subview to DemoView. Though, this is something, that is not feasible in the real case.
I feel like I'm missing something simple here, even if it's expected behaviour.
I was able to fix this with a call to secondLevelSubview.layoutIfNeeded() in layoutSubviews().
override func layoutSubviews() {
super.layoutSubviews()
secondLevelSubview.layoutIfNeeded()
firstLevelSubview.layer.cornerRadius = bounds.width / 2
secondLevelSubview.layer.cornerRadius = secondLevelSubview.bounds.width / 2
print("bounds.width: \(bounds.width), contentView.bounds.width: \(firstLevelSubview.bounds.width), backgroundView.bounds.width: \(secondLevelSubview.bounds.width)")
}
The description of layoutIfNeeded() is:
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. Using the view that
receives the message as the root view, this method lays out the view
subtree starting at the root. If no layout updates are pending, this
method exits without modifying the layout or calling any
layout-related callbacks.
So, essentially you have an ordering issue here. The subview is scheduled for layout, and Auto Layout will get to it, but it hasn't done it yet. By calling layoutIfNeeded(), you tell Auto Layout to perform the pending layout immediately so that you can get the updated frame information.
Note: You can also just call self.layoutIfNeeded() and that will layout DemoView and all of its subviews. This would be useful if you had many such subviews and didn't want to have to call layoutIfNeeded() on each of them.
First, Your view is being construed without problem cause you have an initializer based on ‘CGRect’ , so it gets its dimension!
Second,
Your first subview get initialized after its parents view has got its dimensions. First subview dimensions depends on the parent view dimensions which are ready. So no problem here as you mentioned.
But meanwhile your first subview is trying to get its dimensions, your second subview also starts to get its dimensions based on first subview which is not ready yet , so it got nothing.
I suggest make different functions for each subview.
Make your object from the view class with its frame initializer in Your view controller. In ‘viewDidLoad’ call first subview function and in ’viewDidLoadSubView’ call second subview function.
Hope it helps.
You can get the desired result, if you try to access UIView.bounds under main queue.
override func layoutSubviews() {
super.layoutSubviews()
DispatchQueue.main.async {
self.firstLevelSubview.layer.cornerRadius = self.bounds.width / 2
self.secondLevelSubview.layer.cornerRadius = self.secondLevelSubview.bounds.width / 2
print("bounds.width: \(self.bounds.width), contentView.bounds.width: \(self.firstLevelSubview.bounds.width), backgroundView.bounds.width: \(self.secondLevelSubview.bounds.width)")
}
}
//print:
//bounds.width: 64.0, contentView.bounds.width: 64.0, backgroundView.bounds.width: 54.0
But why? So here I dig down into your problem. Here is the calling view hierarchy of your DemoView layoutSubviews method
DemoView
|
|___layoutSubviews
|
|___firstLevelSubview
|
|___layoutSubviews //Here you are trying to access bounds of second view which is still need to be resize or achieve it's bound
|
|___ secondLevelSubview
|
|___layoutSubviews
And so in this case, main queue will help you to get the actual bounds of any UIView.subView.
Another case I did it, I try to add secondLevelSubview to the DemoView itself with the firstLevelSubview constraints like below:
fileprivate func setupViews() {
backgroundColor = UIColor.clear
firstLevelSubview.translatesAutoresizingMaskIntoConstraints = false
firstLevelSubview.layer.masksToBounds = true
firstLevelSubview.backgroundColor = UIColor.green
addSubview(firstLevelSubview)
firstLevelSubview.topAnchor.constraint(equalTo: topAnchor).isActive = true
firstLevelSubview.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
firstLevelSubview.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
firstLevelSubview.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
secondLevelSubview.translatesAutoresizingMaskIntoConstraints = false
secondLevelSubview.layer.masksToBounds = true
secondLevelSubview.backgroundColor = UIColor.magenta
// firstLevelSubview.addSubview(secondLevelSubview)
addSubview(secondLevelSubview) //Here
secondLevelSubview.widthAnchor.constraint(equalTo: firstLevelSubview.widthAnchor, multiplier: 0.84).isActive = true
secondLevelSubview.heightAnchor.constraint(equalTo: firstLevelSubview.heightAnchor, multiplier: 0.84).isActive = true
secondLevelSubview.centerXAnchor.constraint(equalTo: firstLevelSubview.centerXAnchor).isActive = true
secondLevelSubview.centerYAnchor.constraint(equalTo: firstLevelSubview.centerYAnchor).isActive = true
}
And the hierarchy it follows like below:
DemoView
|
|___layoutSubviews
|
|___firstLevelSubview
|
|___layoutSubviews
|
|___secondLevelSubview
|
|___layoutSubviews
So, in the above case if you try to print the UIView.bounds without any main queue you will get the desired result.
Let me know, this helps you to understand the hierarchy.

TableView resizing parent view iOS

This is a problem that has been bugging me for quite some time.
Assume a view that holds a tableView with X items. The goal is to make that view resize so that it is as high as the contents of the tableView.
An approach
Calculate the contents of the tableView in total ( e.g if there are 5 rows and each is 50 units high, its just a multiplication matter ). Then set the tableView constrained at a 0 0 0 0 into the view and set the view height to 250.
This works well for fixed height cell sizes. However!
a) How would the problem be approached for dynamic height cells though with complex constraints in a scenario where resizing happens automatically and the tableHeightForRow is set to UITableViewAutomaticDimension?
b) An idea could be using tableView.contentSize. However when would we retrieve that value safely in order to set the parent view frame accordingly? Is that even possible?
Thanks everyone
If you have a UITableView subclass, you can set up a property observer on the contentSize like this:
override var contentSize: CGSize {
didSet {
// make delegate call or use some other mechanism to communicate size change to parent
}
}
The most straightforward approach to this in my opinion is to use Autolayout. If you take this approach, you can use the contentSize to automatically invalidate the intrinsicContentSize which is what autolayout uses to dynamically size elements (as long as they don't have higher priority placement constraints restricting or explicitly setting their size).
Something like this:
override var contentSize: CGSize {
didSet {
self.invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
return contentSize
}
Then, just add your table view to your parent view hierarchy with valid placement constraints and a content hugging/compression resistance of required.

DTAttributedTextView does not relayout

When the DTAttributedTextView's frame changes the content (text) inside the view is not relayouted. I have the text view in my tableView cell and its height is specified by a layout constraint, which value I change in code. Width changes automatically with the constraints based on the cell width.
I have tried to manually call setNeedsLayout, layoutIfNeeded, relayoutText, setNeedsDisplay. Nothing seems to help. Why is the content layout locked this way?
This seems to work for the height, but not for the width:
override func layoutSubviews() {
super.layoutSubviews()
comment.attributedTextContentView.layoutFrame = DTCoreTextLayoutFrame(
frame: comment.frame,
layouter: comment.attributedTextContentView.layouter)
comment.relayoutText()
}
Its necessary to set the layouter to nil before calling relayoutText
comment.attributedTextContentView.layoutFrame = DTCoreTextLayoutFrame(
frame: comment.frame,
layouter: comment.attributedTextContentView.layouter)
//gets rid of cached layouter
comment.layouter = nil
comment.relayoutText()

Where to update Autolayout constraints when size changes?

I have several UIViews laid out along the bottom of a containing UIView. I want these views to always be equal width, and always stretch to collectively fill the width of the containing view (like the emoji keyboard buttons at the bottom). The way I'm approaching this is to set equal widths to one of the views, then just update the width constraint of that view to be superviewWidth / numberOfViews which will cause all of the other views to update to that same value.
I am wondering where the code to change the constraint constant needs to go. It needs to be set before the keyboard appears on screen for the first time and update when rotating the device.
My first attempt at a solution was to place it in updateViewConstraints and calculate the width via containerView.frame.size.width. But this method is called twice upon load, the first time it calculates the values correctly, but the second time for some reason the containerView's width is 0.0. Another issue is that when rotating, the containerView's width is not the value that it will be after rotation, it's the current value before rotation. But I don't want to wait until after the rotation completes to update the constraint, because the buttons will be the original size then change which will be jarring to the user.
My question is: where is the most appropriate place to put this code? Is there a better way to calculate what the width will be? I can guarantee it will always be the exact same width as the screen width. And I am using Size Classes in Xcode 6, so willRotateToInterfaceOrientation and similar methods are deprecated.
On all classes that implement the UITraitEnvironment protocol the method traitCollectionDidChange will be called when the trait collection changes, like on rotation. This is the appropiate place to manually update the constraints when using the new Size Classes. You can also animate the transition with the method willTransitionToTraitCollection
Basic example:
class ViewController: UIViewController {
var constraints = [NSLayoutConstraint]()
func updateConstraintsWithTraitCollection(traitCollection: UITraitCollection) {
// Remove old constraints
view.removeConstraints(constraints)
// Create new constraints
}
override func willTransitionToTraitCollection(newCollection: UITraitCollection!,
withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator!) {
super.willTransitionToTraitCollection(newCollection, withTransitionCoordinator: coordinator)
coordinator.animateAlongsideTransition({ (context: UIViewControllerTransitionCoordinatorContext!) in
self.updateConstraintsWithTraitCollection(newCollection)
self.view.setNeedsLayout()
}, completion: nil)
}
override func traitCollectionDidChange(previousTraitCollection: UITraitCollection!) {
updateConstraintsWithTraitCollection(traitCollection)
}
}
Besides that I want to recommend Cartography, which is a nice library that helps to make auto layout more readable and enjoyable. https://github.com/robb/Cartography
There is no reason to update the width manually:
Place all the views with equal width in your view with no spacing in between each other
Add an equal width constraint to all of them
Add constraints with 0 width for spacing between sides and each other
Lower the priority of one or more of the equal width constraints just in case the width cannot be divided equally.
Then auto layout will handle everything for you.

Resources