53

Same code like this

collectionLayout.estimatedItemSize = CGSize(width: 50, height: 25)
collectionLayout.itemSize = UICollectionViewFlowLayoutAutomaticSize
collectionLayout.minimumInteritemSpacing = 10 

for _ in 0 ..< 1000 {
    let length = Int(arc4random() % 8)
    let string = randomKeyByBitLength(length)
    array.append(string!)
}
collectionView.reloadData()

cell constraints:

enter image description here

when I run it on iOS 12, it's different. left simulator is iOS 11, and right is iOS 12:

enter image description here

But, when I scroll it, cells's frames will be normal.


Sample project to reproduce the issue: https://github.com/Coeur/StackOverflow51375566

Cœur
  • 37,241
  • 25
  • 195
  • 267
EvilHydra
  • 552
  • 1
  • 5
  • 9
  • I am unable to reproduce the issue as both ios 11 and ios 12 produce the same result. – Pranav Kasetti Jul 31 '18 at 17:44
  • 1
    @PranavKasetti I reproduced it easily in Xcode 10 beta 6 using the given code. I've linked to a sample project in the question for convenience. – Cœur Sep 03 '18 at 09:33
  • Can anyone confirm, this being fixed in Xcode 12 iOS 14? At least for me it seems to work without any hackaround now. – d4Rk Nov 04 '21 at 12:38

8 Answers8

61

For all solutions, note that there is no need to explicitly call reloadData in viewDidLoad: it will happen automatically.

Solution 1

Inspired by Samantha idea: invalidateLayout asynchronously in viewDidLoad.

override func viewDidLoad() {
    super.viewDidLoad()

    //[...]

    for _ in 0 ..< 1000 {
        array.append(randomKeyByBitLength(Int(arc4random_uniform(8)))!)
    }

    DispatchQueue.main.async {
        self.collectionView.collectionViewLayout.invalidateLayout()
    }
}

Solution 2

(imperfect, see DHennessy13 improvement on it)

Based on Peter Lapisu answer. invalidateLayout in viewWillLayoutSubviews.

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    collectionView.collectionViewLayout.invalidateLayout()
}

As noted by DHennessy13, this current solution with viewWillLayoutSubviews is imperfect as it will invalidateLayout when rotating the screen.

You may follow DHennessy13 improvement regarding this solution.

Solution 3

Based on a combination of Tyler Sheaffer answer, Shawn Aukstak port to Swift and Samantha idea. Subclass your CollectionView to perform invalidateLayout on layoutSubviews.

class AutoLayoutCollectionView: UICollectionView {

    private var shouldInvalidateLayout = false

    override func layoutSubviews() {
        super.layoutSubviews()
        if shouldInvalidateLayout {
            collectionViewLayout.invalidateLayout()
            shouldInvalidateLayout = false
        }
    }

    override func reloadData() {
        shouldInvalidateLayout = true
        super.reloadData()
    }
}

This solution is elegant as it doesn't require to change your ViewController code. I've implemented it on branch AutoLayoutCollectionView of this sample project https://github.com/Coeur/StackOverflow51375566/tree/AutoLayoutCollectionView.

Solution 4

Rewrite UICollectionViewCell default constraints. See Larry answer.

Solution 5

Implement collectionView(_:layout:sizeForItemAt:) and return cell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize). See matt answer.

Cœur
  • 37,241
  • 25
  • 195
  • 267
  • The 3rd solution also has layout warnings on `iOS 12` - `Xcode 10` – Stan Sep 24 '18 at 13:03
  • after trying to change collectionView items number by adding or removing them. – Stan Sep 24 '18 at 13:16
  • @Stan You may provide an answer regarding which solution is compatible with collection view editing, and eventually an implementation example which illustrate the issue with editing (maybe a fork from my github?) – Cœur Sep 24 '18 at 14:11
  • 1
    the first solution worked for me, but I have no idea what was wrong because without that line I've done that in other view controllers, the only difference is in this view controller I have more than 1 collectionView – Arash Afsharpour Jan 02 '20 at 20:11
  • 1
    Solution 3 combine with Larry's answer work for me in iOS 12.2 using xib files. If using collection view in storyboard, solution 3 is enough – Tà Truhoada Apr 24 '20 at 07:42
  • What if our collectionView was inside a tableView cell? (for solution 3) – Ahmadreza Jul 10 '21 at 10:39
32

The problem is that the feature being posited here — collection view cells that size themselves based on their internal constraints in a UICollectionViewFlowLayout — does not exist. It has never existed. Apple claims that it does, but it doesn't. I have filed a bug on this every year since collection views were introduced and this claim was first made; and my bug reports have never been closed, because the bug is real. There is no such thing as self-sizing collection view cells.

See also my answer here: https://stackoverflow.com/a/51585910/341994

In some years, trying to use self-sizing cells has crashed. In other years, it doesn't crash but it gets the layout wrong. But it doesn't work.

The only way to do this sort of thing is to implement the delegate method sizeForItemAt and supply the size yourself. You can easily do that by calling

cell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)

on a model cell that you have configured in advance. That is what the runtime should be doing for you — but it doesn't.

So here's my solution to the original question. Instead of a simple array of strings, we have generated an array of string-size pairs (as a tuple). Then:

override func collectionView(_ collectionView: UICollectionView, 
    cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! MyCell
        cell.label.text = self.array[indexPath.row].0
        return cell
}

func collectionView(_ collectionView: UICollectionView, 
    layout collectionViewLayout: UICollectionViewLayout, 
    sizeForItemAt indexPath: IndexPath) -> CGSize {
        return self.array[indexPath.row].1
}
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • 1
    What do you mean with "cell that you have configured in advance"? Where do you actually make the call to `cell.contentView.systemLayoutSizeFitting(:)`? – d4Rk Sep 25 '18 at 15:54
  • @d4Rk easier to show than to describe, see https://raw.githubusercontent.com/mattneub/Programming-iOS-Book-Examples/3ecfba2ec68673c125aca6e276a44d43a4cee947/bk2ch08p467collectionViewLayoutFromScratchSwift/ch21p748collectionViewFlowLayout2/ViewController.swift – matt Sep 25 '18 at 16:03
  • Ok, so basically you instantiate a cell by yourself, then in `sizeForItemAt` configure it with the data for that very indexPath, then let `systemLayoutSizeFitting` "do the auto layout" and return that size. As this may be the correct solution, it still is a lot to do, but seems like there is no really nice solution, as long as apple doesn't fix this. – d4Rk Sep 26 '18 at 07:04
  • Thanks for the fix and code sample @matt. Any radar id I could dupe? – Guillaume Algis Nov 07 '18 at 15:37
  • @GuillaumeAlgis My report that self-sizing cells were crashing (18523277) was closed in 2015 as a duplicate. I later (in 2016) submitted a report that the layout was coming out wrong if you tried to use self-sizing cells (28044647) and this remains open. – matt Nov 07 '18 at 19:52
  • When I use `systemLayoutSizeFitting`, it doesn't work correctly for multiline content because it is not constrained to the collectionView width. – Cœur Apr 02 '19 at 08:56
29

Here's another solution that works on Cœur's code sample, and also worked for my particular case, where the other answers didn't. The code below replaces the previous implementation of the CollectionViewCell subclass in ViewController.swift:

class CollectionViewCell: UICollectionViewCell {
    @IBOutlet weak var label: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()

        contentView.translatesAutoresizingMaskIntoConstraints = false

        let leftConstraint = contentView.leftAnchor.constraint(equalTo: leftAnchor)
        let rightConstraint = contentView.rightAnchor.constraint(equalTo: rightAnchor)
        let topConstraint = contentView.topAnchor.constraint(equalTo: topAnchor)
        let bottomConstraint = contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
        NSLayoutConstraint.activate([leftConstraint, rightConstraint, topConstraint, bottomConstraint])
    }
}

This is inspired by the answer by ale84 from UICollectionViewFlowLayout estimatedItemSize does not work properly with iOS12 though it works fine with iOS 11.*

Larry
  • 421
  • 5
  • 8
  • 1
    This approach still has layout warnings on `inserting` / `deleting` items `collectionView` events :( – Stan Sep 24 '18 at 13:01
  • 1
    This approach fixes the problem completely for me. Thanks. – Oliver Zhang Oct 07 '18 at 02:47
  • Excellent. The other solutions didn't work for me but this does. – Swindler Oct 10 '18 at 20:07
  • Brilliant, and so simple. Should definitely be the accepted answer. – Ashley Mills Oct 11 '18 at 16:31
  • @OliverZhang please share with me a small sample project where some other solutions wouldn't work, so that I can analyze what is the cause. Thank you. – Cœur Oct 25 '18 at 12:34
  • @Swindler please share with me a small sample project where some other solutions wouldn't work, so that I can analyze what is the cause. Thank you. – Cœur Oct 25 '18 at 12:34
  • @LulzCow please share with me a small sample project where some other solutions wouldn't work, so that I can analyze what is the cause. Thank you. – Cœur Oct 25 '18 at 12:35
  • Unbelievable.. I spent a day on this, just assumed the contentView would have these setup already! Thank you! – Jack Dewhurst Jun 04 '19 at 11:24
12

I have the same problem, cells use the estimated size (instead of automatic size) until scrolled. The same code built with Xcode 9.x runs perfectly fine on iOS 11 and 12, and built in Xcode 10 it runs correctly on iOS 11 but not iOS 12.

The only way I’ve found so far to fix this is to invalidate the collection view’s layout either in viewDidAppear, which can cause some jumpiness, or in an async block inside viewWillAppear (not sure how reliable that solution is).

override func viewDidLoad() {
    super.viewDidLoad()
    let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout
    layout?.estimatedItemSize = CGSize(width: 50, height: 50)
    layout?.itemSize = UICollectionViewFlowLayout.automaticSize
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    // The following block also "fixes" the problem without jumpiness after the view has already appeared on screen.
    DispatchQueue.main.async {
        self.collectionView.collectionViewLayout.invalidateLayout()
    }
}

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    // The following line makes cells size properly in iOS 12.
    collectionView.collectionViewLayout.invalidateLayout()
}
Samantha
  • 2,269
  • 2
  • 13
  • 25
  • It works, thanks. and write an async block inside viewWillAppear looks better than viewDidApear. – EvilHydra Aug 30 '18 at 08:45
  • I found alternative ways to call `invalidateLayout` instead of `viewWillAppear`. – Cœur Sep 03 '18 at 12:50
  • I had to combine this with a manual NSString Size call, and basically just manually returned the size. But the functions weren't being called until doing what you said. "sizeforitematindexpath" is becoming 0 in iOS 12, basically, even with this answer – Stephen J Oct 15 '18 at 23:26
6

Solution 2 of Cœur's prevents the layout from flashing or updating in front of the user. But it can create problems when you rotate the device. I'm using a variable "shouldInvalidateLayout" in viewWillLayoutSubviews and setting it to false in viewDidAppear.

private var shouldInvalidateLayout = true

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    shouldInvalidateLayout = false
}

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    if shouldInvalidateLayout {
        collectionView.collectionViewLayout.invalidateLayout()
    }
}
Cœur
  • 37,241
  • 25
  • 195
  • 267
  • 1
    This is the only one worked for me without blinks. How is possible for this to change without any explanation from Apple? They will just say look at the documentation and on next version another bug that doesn't make any sense. – Andreas777 Sep 20 '18 at 08:53
  • @Andreas777 please share with me a small sample project where some other solutions wouldn't work, so that I can analyze what is the cause. Thank you. – Cœur Oct 25 '18 at 12:35
3

Try this

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    
    DispatchQueue.main.async {
        self.collectionView.collectionViewLayout.invalidateLayout()
    }
}

Adding to viewDidAppear and viewWillAppear will of course work. But viewDidAppear will cause a glitch for the user.

Sreedeepkesav M S
  • 1,165
  • 14
  • 16
0

We had the same problem on our project. We also noticed differences between the multiple devices in iOS 12, requiring a call to layoutIfNeeded & invalidateLayout. The solution is based on @DHennessy13 's approach but doesn't require a boolean to manage states that seemed slightly hacky.

Here it is based on an Rx code, basically the first line is when the data is changing, inside the subscribe is what needs to be done to fix the nasty iOS 12 UI glitch:

        viewModel.cellModels.asObservable()
            .subscribe(onNext: { [weak self] _ in
                // iOS 12 bug in UICollectionView for cell size
                self?.collectionView.layoutIfNeeded()

                // required for iPhone 8 iOS 12 bug cell size
                self?.collectionView.collectionViewLayout.invalidateLayout()
            })
            .disposed(by: rx.disposeBag)

Edit:

By the way, it seems to be a known issue in iOS 12: https://developer.apple.com/documentation/ios_release_notes/ios_12_release_notes (in UIKit section).

Cœur
  • 37,241
  • 25
  • 195
  • 267
Toka
  • 903
  • 7
  • 12
  • 4
    Replacing a Bool with [RxSwift](https://github.com/ReactiveX/RxSwift) isn't a simplification. And the issue you're linking to has an official advice of simply adding a call `updateConstraintsIfNeeded()`. – Cœur Sep 21 '18 at 02:08
  • I am not replacing a Bool by RxSwift, no need of a hammering machine for a simple nail. The post illustrates how to do it via RxSwift since I use this approach in my project, which also brings the advantage to prevent the hacky Bool trick in this case. See it as another solution to help folks that use Rx to answer the initial problem. // Also, if you read carefully, I am only pointing out that Apple is aware of the problem, I am not suggesting their official advice that doesn't work for me anyway (neither for you I suppose since you're coming up with different ways to fix the problem). – Toka Sep 25 '18 at 14:52
  • I don't have collection views with dynamic sizes in my apps, so I didn't explore `updateConstraintsIfNeeded` yet: my answer was just an attempt to help. :) Well, it's nice that you're proposing an RxSwift solution. – Cœur Sep 25 '18 at 15:01
  • Oh my bad, I thought you did had dynamic sizes. Thanks for the message :) – Toka Sep 25 '18 at 15:17
0
class CollectionViewCell: UICollectionViewCell {
@IBOutlet weak var label: UILabel!

override func awakeFromNib() {
    super.awakeFromNib()

    contentView.translatesAutoresizingMaskIntoConstraints = false

    let leftConstraint = contentView.leftAnchor.constraint(equalTo: leftAnchor)
    let rightConstraint = contentView.rightAnchor.constraint(equalTo: rightAnchor)
    let topConstraint = contentView.topAnchor.constraint(equalTo: topAnchor)
    let bottomConstraint = contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
    NSLayoutConstraint.activate([leftConstraint, rightConstraint, topConstraint, bottomConstraint])
}}

I've tried this and it's worked for me. Try this and let me know if you need any help.