Search code examples
iosperformancerunloop

How to, simply, wait for any layout in iOS?


Before beginning note that this has nothing to do with background processing. There is no "calculation" involved that one would background.

Only UIKit.

view.addItemsA()
view.addItemsB()
view.addItemsC()

Let's say on a 6s iPhone

EACH of these takes one second for UIKit to construct.

This will happen:

one big step

THEY APPEAR ALL AT ONCE. To repeat, the screen simply hangs for 3 seconds while UIKit does a massive amount of work. Then they all appear at once.

But let's say I want this to happen:

enter image description here

THEY APPEAR PROGRESSIVELY. The screen simply hangs for 1 second while UIKit builds one. It appears. It hangs again while it builds the next one. It appears. And so on.

(Note "one second" is just a simple example for clarity. See the end of this post for a fuller example.)

How do you do it in iOS?

You can try the following. It does not seem to work.

view.addItemsA()

view.setNeedsDisplay()
view.layoutIfNeeded()

view.addItemsB()

You can try this:

 view.addItemsA()
 view.setNeedsDisplay()
 view.layoutIfNeeded()_b()
 delay(0.1) { self._b() }
}

func _b() {
 view.addItemsB()
 view.setNeedsDisplay()
 view.layoutIfNeeded()
 delay(0.1) { self._c() }...
  • Note that if the value is too small - this approach simply, and obviously, does nothing. UIKit will just keep working. (What else would it do?). If the value is too big, it's pointless.

  • Note that currently (iOS10), if I'm not mistaken: if you try this trick with the trick of a zero delay, it works erratically at best. (As you'd probably expect.)

Trip the run loop...

view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()

RunLoop.current.run(mode: RunLoop.Mode.default, before: Date())

view.addItemsB()
view.setNeedsDisplay()
view.layoutIfNeeded()

Reasonable. But our recent real life testing shows that this seems to NOT work in many cases.

(ie, Apple's UIKit is now sophisticated enough to smear UIKit work beyond that "trick".)

Thought: is there perhaps a way, in UIKit, to get a callback when it has, basically, drawn-up all the views you've stacked up? Is there another solution?

One solution seems to be .. put the subviews in controllers, so you get a "didAppear" callback, and track those. That seems infantile, but maybe it's the only pattern? Would it really work anyway? (Merely one issue: I don't see any guarantee that didAppear ensures all subviews have been drawn.)


In case this still isn't clear...

Example everyday use case:

• Say there are perhaps seven of the sections.

• Say each one typically takes 0.01 to 0.20 for UIKit to construct (depending on what info you're showing).

• If you just "let the whole thing go in one whack" it will often be OK or acceptable (total time, say 0.05 to 0.15) ... but ...

• there will often be a tedious pause for the user as the "new screen appears". (.1 to .5 or worse).

• Whereas if you do what I am asking about, it will always smooth on to the screen, one chunk at a time, with the minimum possible time for each chunk.


Solution

  • The window server has final control of what appears on screen. iOS only sends updates to the window server when the current CATransaction is committed. To make this happen when it is needed, iOS registers a CFRunLoopObserver for the .beforeWaiting activity on the main thread's run loop. After handling an event (presumably by calling into your code), the run loop calls the observer before it waits for the next event to arrive. The observer commits the current transaction, if there is one. Committing the transaction includes running the layout pass, the display pass (in which your drawRect methods are called), and sending the updated layout and contents to the window server.

    Calling layoutIfNeeded performs layout, if needed, but doesn't invoke the display pass or send anything to the window server. If you want iOS to send updates to the window server, you must commit the current transaction.

    One way to do that is to call CATransaction.flush(). A reasonable case to use CATransaction.flush() is when you want to put a new CALayer on the screen and you want it to have an animation immediately. The new CALayer won't be sent to the window server until the transaction is committed, and you can't add animations to it until it's on the screen. So, you add the layer to your layer hierarchy, call CATransaction.flush(), and then add the animation to the layer.

    You can use CATransaction.flush to get the effect you want. I don't recommend this, but here's the code:

    @IBOutlet var stackView: UIStackView!
    
    @IBAction func buttonWasTapped(_ sender: Any) {
        stackView.subviews.forEach { $0.removeFromSuperview() }
        for _ in 0 ..< 3 {
            addSlowSubviewToStack()
            CATransaction.flush()
        }
    }
    
    func addSlowSubviewToStack() {
        let view = UIView()
        // 300 milliseconds of “work”:
        let endTime = CFAbsoluteTimeGetCurrent() + 0.3
        while CFAbsoluteTimeGetCurrent() < endTime { }
        view.translatesAutoresizingMaskIntoConstraints = false
        view.heightAnchor.constraint(equalToConstant: 44).isActive = true
        view.backgroundColor = .purple
        view.layer.borderColor = UIColor.yellow.cgColor
        view.layer.borderWidth = 4
        stackView.addArrangedSubview(view)
    }
    

    And here's the result:

    CATransaction.flush demo

    The problem with the above solution is that it blocks the main thread by calling Thread.sleep. If your main thread doesn't respond to events, not only does the user get frustrated (because your app isn't responding to her touches), but eventually iOS will decide that the app is hung and kill it.

    The better way is simply to schedule the addition of each view when you want it to appear. You claim “it's not engineering”, but you are wrong, and your given reasons make no sense. iOS generally updates the screen every 16⅔ milliseconds (unless your app takes longer than that to handle events). As long as the delay you want is at least that long, you can just schedule a block to be run after the delay to add the next view. If you want a delay of less than 16⅔ milliseconds, you cannot in general have it.

    So here's the better, recommended way to add the subviews:

    @IBOutlet var betterButton: UIButton!
    
    @IBAction func betterButtonWasTapped(_ sender: Any) {
        betterButton.isEnabled = false
        stackView.subviews.forEach { $0.removeFromSuperview() }
        addViewsIfNeededWithoutBlocking()
    }
    
    private func addViewsIfNeededWithoutBlocking() {
        guard stackView.arrangedSubviews.count < 3 else {
            betterButton.isEnabled = true
            return
        }
        self.addSubviewToStack()
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
            self.addViewsIfNeededWithoutBlocking()
        }
    }
    
    func addSubviewToStack() {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.heightAnchor.constraint(equalToConstant: 44).isActive = true
        view.backgroundColor = .purple
        view.layer.borderColor = UIColor.yellow.cgColor
        view.layer.borderWidth = 4
        stackView.addArrangedSubview(view)
    }
    

    And here's the (identical) result:

    DispatchQueue asyncAfter demo