Autosizing cells in UICollectionViewLayout (supporting iOS 9) without `invalidateLayout` jump - ios

I have been reading this article about using custom UICollectionViewLayouts and trying to incorporate the idea into a project I'm working on.
We were previously using a UICollectionViewFlowLayout subclass with a huge ugly function to determine the size of the cells by dequeueing a prototype cell and populating it and then asking it for its size.
Needless to say, I wanted to find a better way of doing this.
The problem I'm having is with the initial layout of the cells. A lot of the cells that we use contain a 1 or 2 labels that may contain a lot of text and need to wrap. In the linked article there is a playground which shows multi-line labels that size automatically but it is inserting line breaks into the text to force this...
In the Datasource.swift file...
lazy private var values: [String] = {
return (0...255).map {
_ in
switch arc4random_uniform(3) {
case 0: return "Hello"
case 1: return "Hello\nGoodbye"
default: return "Hello\nGoodbye\nAu revoir"
}
}
}()
However, if I change these to strings of different length that would break naturally it breaks the example. The initial layout only ever has one line in the label. But then, on scrolling the labels display correctly.
I can "fix" this by adding the line layout.invalidateLayout() in the Autosizing file.
Now, I'm in my own project and trying to do the same thing. Using the layout.invalidateLayout() only works in the view controller if I put it in the viewDidAppear function. This means that we get an awkward jump after the transition the layout is incorrect and then "jumps" to the correct layout. However, I feel like the entire approach of using invalidateLayout is broken.
Is there a way I can get this to not break on the initial layout? Or to intelligently invalidate the cells initial layout?
I added a GitHub project that shows the problem I am having. Currently it is laying out correctly because of the code...
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// This invalidation "fixes" the layout but causes a jump in the UI
collectionView?.collectionViewLayout.invalidateLayout()
}
Ideally I'd like to remove this and have the layout "just work".

The problem here is that on the initial pass, the preferred layout attributes returned by the cell are incorrect - it sizes the label based on a seemingly limitless width, so you end up with it wanting to fit on one line.
Adding this line to the cell setUp() method "fixes" the problem:
label.preferredMaxLayoutWidth = self.bounds.width - (contentView.layoutMargins.left + contentView.layoutMargins.right)
It's a bit dirty, I'm sure there is a better way.
EDIT
OK, here's a better way. This forces your views into the original estimated frame from the layout, giving the AL engine a chance to size the cell properly. Do this in your cell subclass:
private var isInitialLayout = true
public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
if isInitialLayout {
self.contentView.layoutIfNeeded()
isInitialLayout = false
}
return super.preferredLayoutAttributesFitting(layoutAttributes)
}

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?

UICollectionView cellForItemAt: won't be called while layoutAttributesForElements are returning values

I have a bit complex UICollectionView with my custom UICollectionViewLayout.
I added a pinch gesture behavior onto it and adjust the layout according to that result.
It's basically working pretty well, the cells are resized and repositioned properly in normal cases.
But when it comes to a particular condition the cells disappear and never appear again.
So far I'm unable to clarify the condition, but it happens often when you pinched the view to a smaller size.
The issue is, layoutAttributesForElements in my collection view layout is called (of course this also implies that numberOfCells:inSection is called as well) and it's returning reasonable cell geometry, but actual cell generation (cellForItemAt:) won't be called.
Before jumping into the code(as it's a bit too long and complicated) I want to ask you guys if any of you have had the same kind of situation.
Below is the summary of what's happening and what I see so far.
It's not that always happening. It happens only after pinching and reached to a certain condition. So this is not a basic kind of how-to-use-UICollectionView issue.
Even when it happens layoutAttributesForElements keeps being called (as you keep pinching)
The layout attributes don't have crazy values like zero size or position of far out of view range. They all have the position attributes that fit into the collection view's content size.
Collection view claims the proper content view size (at least as reported on the debugger.)
When it happens you cannot see any cells in the view hierarchy in the View Debugger. Meaning, it's not zero sized or clear colored but cells themselves are gone. This corresponds to the fact that cellForItemAt: is not called.
After it happened you cannot see the cells anymore even you pinch the screen back to the original scale.
Any information is appreciated. Thanks!
EDIT
My collection view layout is like this. This project is a musical sequencer and the collection view is showing your musical notes in piano roll style.
//
// YMPianoRollLayout.swift
//
import UIKit
class YMPianoRollLayout: UICollectionViewLayout {
let notes : Array<CGPoint> = []
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!;
}
override var collectionViewContentSize : CGSize {
let cv = self.collectionView as! YMPianoRollCollectionView
let screenInfo = cv.pianoRollViewController.screenInfo
let totalSize = screenInfo.collectionViewContentsSize();
print("contentSize = \(totalSize)")
return totalSize;
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// "screenInfo" keeps the user specified view parameters including the scale ratio by the pinch gesture
let cv = self.collectionView as! YMPianoRollCollectionView;
let pianoRoll = cv.pianoRollViewController;
// Check which musical note can be included in the view rect
let indexArray: Array<Int> = pianoRoll!.getNoteIndexes(inRect:rect, useOnlyStartTime: false);
var retArray : [UICollectionViewLayoutAttributes] = []
for i in indexArray {
if let _ = pianoRoll?.pattern.eventSequence[i] as? YMPatternEventNoteOn {
retArray.append( self.layoutAttributesForPatternEventInfo(i) )
}
}
// This always reports non-zero count. Also checked the positions of each array members
// by breaking here and they all had proper size and positions
print("retArray count = \(retArray.count)");
return retArray
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let i = Int((indexPath as NSIndexPath).row)
return self.layoutAttributesForPatternEventInfo(i)
}
//
// This is my specific func to convert the musical time-pitch into the view geometory
//
func layoutAttributesForPatternEventInfo(_ index: Int) -> UICollectionViewLayoutAttributes!{
let cv = self.collectionView as! YMPianoRollCollectionView
// "screenInfo" keeps the user specified view parameters including the scale ratio by the pinch gesture
let screenInfo = cv.pianoRollViewController.screenInfo
// Retrieve musical event
let event = cv.pianoRollViewController.pattern.eventSequence[index]
let index = IndexPath(row:index, section: 0)
let newAttr = UICollectionViewLayoutAttributes(forCellWith:index)
var frame : CGRect!
if let e = event as? YMPatternEventNoteOn {
let noteNo = e.noteNo;
let origin = YMMusicalValuePoint(time:event.time, noteNumber:noteNo);
let size = YMMusicalValueSize(timeLength:e.duration, numberOfNotes: 1);
// Actual size calculation is done in my "screenInfo" class.
frame = screenInfo.getPhysicalRange(YMMusicalValueRange(origin:origin, size:size));
} else {
frame = CGRect(x: 0, y: 0, width: 0, height: 0);
}
newAttr.frame = frame;
newAttr.zIndex = 1;
return newAttr
}
//
// For checking the bounds
//
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
print("BBB newBounds = \(newBounds)")
return true
}
}
Sounds like an interesting problem...
More info on your custom UICollectionViewLayout subclass (i.e. the code) would be useful but I can offer an overview of how UICollectionView typically updates visible content in response to bounds changes (i.e. content offset, rotation) and invalidateLayout calls.
When invalidateLayout fires the internal attributes cache is cleared signaling the need to ask the layout on the next layoutSubviews pass what should be visible. Conversely, if the layout is not invalidated and the attributes are known in the cache the layout will not be asked.
The magic all happens when the current CA transaction commits and UICollectionView's layoutSubviews is invoked. At this point, a diff between what was last known to be on screen and what should now be on screen is computed according the current "visible bounds" (effectively the scroll view's bounds which includes the offset into the layout).
Cells no longer visible in the new visible bounds will be returned to the reuse queue and newly appearing cells (as per the layout) will be constructed and existing items (that have changed) will be updated with their new attributes.
Based on your description, it sounds like when the next layoutSubviews fires the queried attributes (possibly from the cache!) aren't returning anything for the new visible bounds therefore nothing "new" appears -- along with the existing items disappearing...
A few things to investigate might include:
Is your gesture causing your custom layout to invalidate? If not, it probably should so the UICollectionView knows to not trust it's internal attributes cache and always ask the layout for new attributes during the "diffing" process.
How is the bounds of the collection changing during the pinch gesture?
This directly affects the diff since it will use this visible bounds to determine what should be displayed next.
How does your layout respond to shouldInvalidateForBoundsChange:?
Most layouts only invalidate when the extents (e.g. rotation) change, so the UICollectionView will normally rely on it's cached attributes when performing the diff. In your case if you are tweaking the bounds during the gesture, you'll want to unconditionally return YES here.
When you get into this wonky state, you might try pausing into the debugger e.g.
po [[cv collectionViewLayout] invalidateLayout]
po [cv setNeedsLayout]
po [cv layoutIfNeeded]
...and resume. Has everything re-appeared?
If so, it sounds like the layout isn't being invalidated under certain circumstances and the attributes being returned are indeed reasonable.
If not, ping the layout and see what if it is reporting as visible is reasonable via:
po [[cv collectionViewLayout] layoutAttributesForElementsInRect:cv.bounds]

Why does sizeThatFits() return a size that is too small?

I'm learning swift with cs193p and I have a problem with UITextView.sizeThatFits(...). It should return a recommended size for popover view to display an [int] array as a text. As you can see in Paul Hegarty's example (https://youtu.be/gjl2gc70YHM?t=1h43m17s), he gets perfectly-fit popover window without scrollbar. I'm using almost the same code that was in this lecture, but instead i've got this:
the text string equals [100], but the sizeThatFits() method is returning a size that is too small to display it nicely, even though there is plenty of free space.
It is getting a bit better after I've added some text, but still not precise and with the scrollbar:
Here is the part of the code where the size is being set:
override var preferredContentSize: CGSize {
get {
if textView != nil && presentingViewController != nil {
// I've added these outputs so I can see the exact numbers to try to understand how this works
print("presentingViewController!.view.bounds.size = \(presentingViewController!.view.bounds.size)")
print("sizeThatFits = \(textView.sizeThatFits(presentingViewController!.view.bounds.size))")
return textView.sizeThatFits(presentingViewController!.view.bounds.size)
} else { return super.preferredContentSize }
}
set { super.preferredContentSize = newValue }
}
What should I do so this will work in the same way as in the lecture?
It looks like there are 16 pt margins between the label and its parent view. You need to take that into account when returning the preferred size of the popover.
You should try both of the following:
Add 32 to the width that's returned from preferredContentSize
In Interface Builder, clear the layout constraints on your UILabel, then re-add top, bottom, leading, and trailing constraints and make sure that "Constrain to Margins" option is not enabled.
Finally, instead of overriding preferredContentSize, you can simply set the preferredContentSize when your view is ready to display, and you can ask Auto Layout to choose the best size:
override func viewDidLayoutSubviews() {
self.preferredContentSize = self.view.systemLayoutSizeFitting(UILayoutFittingCompressedSize)
}
If your layout is configured correctly, systemLayoutSizeFitting(UILayoutFittingCompressedSize) will return the smallest possible size for your view, taking into account all of the margins and sub-views.

Insert in a tableView set to Autoresize cause a scroll issue

I've an issue for few days and really I can't explain why it goes like that.
I'm doing a chat, set up with a tableView, printing message into cell. These cells are designed with prototypes, there are 3 different type (but anyway it doesn't matter). Text is typed, message is send, cell is inserted in table and then we scroll to the bottom of the tableView.
As you know, in a chat view the container of the message has to fit this text (which is a view), and then the cell has to fit to this container (Label in orange, container in purple).
This container has a variable height and grow along the text, changing cell height.
I've set many constraint for auto-layout display but I didn't have different cell height than the height I initially set for it in the project (no adaptative behaviour). Chat Message were cut.
So, I've tried to set each rowHeight by myself using the method heightForRowAtIndexPath, calculating constraint along text size but it create a real bad behaviour (changing cells when scrolling for example). The size was often wrong calculated/recalculated.
That's why I'm finally using estimatedRowHeight set to 44 combine with UITableViewAutomaticDimension. Here it does the trick ! Waouh ! But it's not as good as expected..
When the table view appears all is good. Containers fit to their label, rows' height fit to their container and it's beautiful. The problem appears during an insert.
After many tests, I notice that this bad behaviour only appears when multilines label remains in the table view.
During an insert, each cell seems to resize to 44 before adapt its height to content, and then create a gap compared to previous state which create a strange scroll of the table view :
If I change the estimatedRowHeight it made this worst.
I can show my code if you want, but I don't think it will be very useful here because I'm not using complicated function.. only insert, automatic height for cells and scroll down which are functions delegate for tableView.
Can you help me please ? Really I don't know how to change that.. every chat application does the trick but I can't found out the way.
Thank you for answer, excuse my english level I'm a poor french student..
If you need some code comment I'll give it.
I think you can cache height value for every cell in cellForRowAtIndex method and in HeightForRowAtIndex set the corresponding height for that cell.
You can just do this way :
var cellCached = Dictionary<Int,AnyObject>()
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell
{
table.rowHeight = yourCell.yourLabel.frame.origin.y + yourCell.yourLabel.frame.height + 10
cellCached[indexPath.row] = yourTable.rowHeight
}
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
if(tableView == yourTable)
{
if(cellCached[indexPath.row] != nil)
{
return cellCached[indexPath.row] as! CGFloat
}
else
{
return 200
}
}
else
{
return UITableViewAutomaticDimension
}
}

UIWebView in UIScrollView with autolayout

I am having lots of trouble with this setup. So basically I am displaying some labels with variable height then a button and at the end of the view i need a WebView to display some HTML formatted text.
I resize the web view height constraint when the content is loaded as follows.
func webViewDidFinishLoad(webView: UIWebView) {
nContentViewHeight.constant = webView.scrollView.contentSize.height
}
When the view is first loaded, this works perfectly. The scrolling is good and all the web view content is visible.
But when I rotate the device I don't know how to properly resize the web view. I tried loading the content again in
override func didRotateFromInterfaceOrientation(fromInterfaceOrientation: UIInterfaceOrientation)
so that webViewDidFinishLoad would trigger again and resize the web view. But that doesn't work at all because the contentSize of the scrollview inside the web view doesn't change.
Not knowing why I attempted very ugly solution and that is this:
func fitWebAndScrollView(){
let newRect = CGRectMake(nContentWebView.frame.minX, nContentWebView.frame.minY, self.view.frame.width, 10)
let newWebView = UIWebView(frame: newRect)
newWebView.delegate = self
newWebView.tag = -12
newWebView.scrollView.scrollEnabled = false
self.view.addSubview(newWebView)
newWebView.loadHTMLString(contentHtml, baseURL: nil)
}
override func didRotateFromInterfaceOrientation(fromInterfaceOrientation: UIInterfaceOrientation) {
fitWebAndScrollView()
}
func webViewDidFinishLoad(webView: UIWebView) {
nContentViewHeight.constant = webView.scrollView.contentSize.height
if webView.tag == -12 {
webView.removeFromSuperview()
nContentViewHeight.constant *= 1.1 // longer the content is more of it is clipped
}
}
And this sort of works, but in some instance the bottom of the WebView content is clipped as if the inner scrollview content size is calculated incorrectly.
Has anybody dealt with this before? I always assumed that this sort of thing wasn't an extra special use case.
Thank you for your ideas.
Aright so I managed to solve it this way
func fitWebAndScrollView(){
if let strH = nContentWebView.stringByEvaluatingJavaScriptFromString("document.getElementById(\"cnt\").offsetHeight;"),
numH = NSNumberFormatter().numberFromString(strH){
nContentViewHeight.constant = CGFloat(numH)+20
}
}
override func didRotateFromInterfaceOrientation(fromInterfaceOrientation: UIInterfaceOrientation) {
fitWebAndScrollView()
}
func webViewDidFinishLoad(webView: UIWebView) {
fitWebAndScrollView()
}
The problem was actually with the HTML code I was using as a wrapper.
let htmlWrapperString = "<html><head>\n<style type=\"text/css\">body {font-family: \"%#\"; font-size: %#;-webkit-text-size-adjust: none;}</style></head><body><div id=\"cnt\">%#</div></body></html>"
So before I didn't have that wrapping div in the body and when I queried the height of body tag it always returned the whole view port size and not just the text height that was in body.
Now it works reliably.
First of all, if the code is really as shown, you are assigning the width of the content to the height constant. This would definitely cause the issue. (Apparently it is not as shown; please show actual code instead of typing in new "similar" code into the question…)
Second, it is not a very reliable way to detect the content height by only doing it in webViewDidFinishLoad. On some sites it may take a long time before this is actually called but the content may still be usable, and of course any action by the user in the browser may change it, and many websites append more content to the end of the page on the fly (e.g., as the user scrolls down).
Also, I hope you realize that scrollView.contentSize is the size of the content itself (not the visible part), and its height won't change on rotation unless the width is changed in a way that makes the content layout change.
Overall I think you may be trying to do something that UIWebView is simply not suited for. You should only autolayout the size of the webview itself, not have it inside an external UIScrollView (if I read the title correctly and that's what you are doing). Its inner scrollView will then handle the content scrolling for you automatically, instead of conflicting with your external scrollView. You can set yourself up as the delegate of the inner scrollview if you need to react to events from it, etc.
To resize the UIWebView itself on rotation, set up constraints to bind the distance from each of its edges to the surrounding views (or use the simpler autoresizingMask). There should be no need to do anything in didRotateFromInterfaceOrientation (and if you do need manual layout, do it in viewWillLayoutSubviews and/or viewDidLayoutSubviews).

Resources