Skip to content
academia.sh

Lesson 12 / 15

Drawing and Layer Animation

A measurement of how a ring drawn with draw(_:) stays stale when no redraw is requested, how a value returns to its old state when the model layer is not written in a layer animation, and how the duration is not measured.

Published Updated

In this lesson

  • Distinguishes that a hand-drawn view stays at the old value without a redraw request
  • Measures the case where the model changed and the screen stayed old, because no redraw was requested, as stale
  • Distinguishes that in a layer animation the model layer carries the final value at once while the presentation layer shows an intermediate value
  • Verifies that the animation duration has no counterpart in the model and that the measurement builds only the model layer
Contents

In the previous lesson, The Delegate Pattern, we measured that a broken link silently leaves the screen stale. In this lesson the break concerns a view’s own surface. Some views are not built from ready-made components; they draw their own content themselves. The drawing code runs only when it is called, and calling it requires a redraw request. Even if the model changes, the drawing stays old unless a request arrives.

The measurement uses the core file.

// Sync.swift — compares every field shown on screen with the model's current value
final class Screen {
    let name: String
    private(set) var shown: [String: String] = [:]

    init(_ name: String) { self.name = name }

    func set(_ key: String, _ value: String) { shown[key] = value }
    func clear(_ key: String) { shown[key] = nil }
}

func verdict(model: String?, shown: String?) -> String {
    switch (model, shown) {
    case (nil, nil): return "out-of-scope"
    case (nil, _?): return "leftover"
    case (_?, nil): return "missing"
    case let (wanted?, displayed?): return wanted == displayed ? "matching" : "stale"
    }
}

func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
    keys.map { key in
        let result = verdict(model: model[key], shown: screen.shown[key])
        print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") screen=\(screen.shown[key] ?? "-") -> \(result)")
        return result
    }
}

func countVerdicts(_ verdicts: [String]) {
    for name in ["matching", "stale", "missing", "leftover"] {
        print("\(name): \(verdicts.filter { $0 == name }.count)")
    }
}

The core compares every field shown on screen with the model’s current value. In this lesson the value on the screen is the value the drawing last drew. The model builds what the code writes to the screen; it is not the framework itself.

The Drawing Stays Old Unless It Is Requested Again

Consider a progress ring on a loading screen. The filled arc of the ring is drawn according to a percentage value. The view does its drawing by overriding the draw(_:) method; the method is called when the view needs to be redrawn.

import UIKit

final class ProgressRingView: UIView {
    var progress: Int = 0

    override func draw(_ rect: CGRect) {
        let path = UIBezierPath(arcCenter: CGPoint(x: rect.midX, y: rect.midY),
                                radius: rect.width / 2,
                                startAngle: -.pi / 2,
                                endAngle: -.pi / 2 + 2 * .pi * CGFloat(progress) / 100,
                                clockwise: true)
        path.stroke()
    }
}

progress is a number and draw(_:) reads it. But when progress changes, draw(_:) is not called by itself. For the view to be redrawn, setNeedsDisplay() has to be called; this call leaves a redraw request with the framework and the drawing runs in the next pass. The measurement has three steps: the ring is drawn, the model changes the percentage, and the request is made.

// main.swift — the ring stays at the old value unless a redraw is requested
let ring = Screen("ProgressRing")
var model = ["progress": "40"]

ring.set("progress", "40")
let drawn = check("first-draw", model: model, screen: ring, keys: ["progress"])

model["progress"] = "70"
let stale = check("model-changed", model: model, screen: ring, keys: ["progress"])

ring.set("progress", "70")
let redrawn = check("redraw-requested", model: model, screen: ring, keys: ["progress"])

countVerdicts(drawn + stale + redrawn)
first-draw | ProgressRing.progress: model=40 screen=40 -> matching
model-changed | ProgressRing.progress: model=70 screen=40 -> stale
redraw-requested | ProgressRing.progress: model=70 screen=70 -> matching
matching: 2
stale: 1
missing: 0
leftover: 0

In the first-draw step it is matching. In the model-changed step the model is 70, the screen is 40 and the result is stale: the ring keeps drawing the old percentage. In the redraw-requested step the drawing ran again and the row is matching. The count is two matching, one stale.

On the SOURCE side, the request can also be made inside the value itself. An observer is added to the property and a request is left every time the value changes.

import UIKit

extension ProgressRingView {
    func update(to percent: Int) {
        progress = percent
        setNeedsDisplay()
    }
}

In this form every path that calls update(to:) also leaves the request. What is measured here is not the drawing itself but whether the request was written.

LC1. This measurement represents the redraw request with the step of writing to the screen. Whether the request ran in the framework’s next pass or whether two requests merged was not measured.

Model Layer and Presentation Layer

Under a view there is a layer. Layer animations let a value move from the old to the new through intermediate values. Two layers appear here: the model layer carries the final state of the value at once, while the presentation layer shows the intermediate value visible on screen at that moment. If an animation is added but the value of the model layer is not written, the layer returns to its old value when the animation ends.

import UIKit

func fadeIn(_ ring: UIView) {
    let animation = CABasicAnimation(keyPath: "opacity")
    animation.fromValue = 0
    animation.toValue = 1
    animation.duration = 0.4
    ring.layer.add(animation, forKey: "fade")
}

func fadeInAndCommit(_ ring: UIView) {
    ring.layer.opacity = 1
    fadeIn(ring)
}

fadeIn only adds the animation; the opacity in the model layer stays old. fadeInAndCommit first writes the final value to the model layer and then adds the animation. Here the model builds only the model layer: the intermediate value visible on screen in the middle of the animation is not measured. We measure the two steps.

// main.swift — if an animation is added and the model layer is not written, the value returns to its old state
let layer = Screen("RingLayer")
let model = ["ringOpacity": "1"]

layer.set("ringOpacity", "0")
let added = check("animation-added", model: model, screen: layer, keys: ["ringOpacity"])

layer.set("ringOpacity", "1")
let committed = check("model-layer-written", model: model, screen: layer, keys: ["ringOpacity"])

countVerdicts(added + committed)
animation-added | RingLayer.ringOpacity: model=1 screen=0 -> stale
model-layer-written | RingLayer.ringOpacity: model=1 screen=1 -> matching
matching: 1
stale: 1
missing: 0
leftover: 0

In the first step the model is 1, the screen is 0 and the result is stale: the animation was added but the value of the layer was not written. In the second step, once the model layer is written, the row is matching. The count is one matching, one stale. The intermediate value that the presentation layer shows in the middle of the animation is in neither of these two rows; the output compares only the value that stays in the layer after the animation ends.

LC2. This measurement represents the model layer as a single value. The intermediate values of the presentation layer, the moment the animation completes and the cancellation of the animation were not measured.

The Framework Calls the Drawing

In a hand-drawn view your code never calls the draw(_:) method directly. The caller is the framework, and it does so when the view is marked as needing drawing, while the screen prepares the next pass. This marking is done with setNeedsDisplay(). The call does not draw at once; it only leaves a request. If several requests arrive in the same pass, the framework merges them and does the drawing once. For this reason, calling setNeedsDisplay() a hundred times in a loop does not mean a hundred drawings.

It is also possible to narrow the scope of the request. setNeedsDisplay(_:) takes a rectangle and asks for only that region to be redrawn; the rect parameter that the draw(_:) method receives carries that region. In a large view where only a small part changed, the drawing cost drops this way.

Layout and Drawing Are Separate Requests

setNeedsDisplay() is for drawing; a change of size or position is requested with setNeedsLayout(). Mixing the two is a common mistake. When the percentage of the ring changes, the drawing has to be refreshed and the size does not change; so the right call is setNeedsDisplay(). When the constant of a constraint changes, layout is needed, not drawing. Whether drawing is needed when the bounds of the view change is decided by contentMode: the default value stretches the view, while the redraw value asks for the drawing again on every bounds change.

import UIKit

final class RingContainer: UIView {
    var progress: Int = 0 {
        didSet { setNeedsDisplay() }
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        // The size changed, the layout was updated; drawing is a separate request.
    }
}

The didSet observer leaves the request by itself every time the value changes. In this way forgetting the request becomes impossible. If a separate update method is written instead of an observer, every path that does not call that method leaves the screen stale.

Two Trees in a Layer Animation

The layer under a view works with two separate layer trees. The model layer carries the value the code wrote. The presentation layer carries the value visible on screen at that moment and walks through the intermediate values during an animation. The presentation layer is reached with the presentation() method and its values can be read, but not written.

When an animation is added, the model layer does not change. The animation drives only the presentation layer; when it ends, the presentation layer returns to the value of the model layer. For this reason the value you want to remain at the end of the animation must first be written to the model layer. There are also the isRemovedOnCompletion and fillMode settings for holding the animation in its end state, but these hide the distinction between the model and the presentation and give a wrong value on the next read. The right way is to write the model layer first and start the animation from the starting value.

Drawing or a Ready-Made Component

Hand drawing is not needed on every screen. Ready-made components such as labels, images and buttons draw text, pictures and touch themselves. The draw(_:) method is used for forms that a ready-made component does not cover, such as a progress ring, a chart or a custom badge. If the drawing path is fixed and the value is only a number, a solution made of layer objects is also possible: a shape layer builds the path once, and the percentage is updated by changing only a property. This is cheaper than redrawing the whole view on every change and combines naturally with layer animation.

The effect of the choice on the measuring axis is this: in hand drawing, the value of the screen is the value at the moment the drawing last ran, and leaving the request counts as a writing step. In the layer solution, the value of the screen is directly in the layer.

Common Mistakes

  • Calling the draw(_:) method directly. There is no valid drawing context and the result is not drawn.
  • Asking for layout instead of setNeedsDisplay(). Layout does not refresh the drawing.
  • Adding an animation and not writing the model layer. When the animation ends, the layer returns to its old value; this is the stale row in the second measurement of this lesson.
  • Writing to the presentation layer. The values are erased in the next pass.
  • Creating new objects inside draw(_:). Memory allocated on every drawing breaks fluidity; the drawing path and colors are prepared outside.
  • Changing state inside drawing. Drawing only reads; if the call that writes the state requests the drawing again, a loop forms.

Interpreting the Measured Result

In the first measurement the ring was drawn when the percentage was 40 and it was matching. When the model became 70, the screen stayed 40 and the row became stale; when the drawing request was left, the row returned to matching. The difference between them is a single call, but in the output only the last value of the screen is visible. In the second measurement the same structure repeated in the layer value: the animation was added, the model layer was not written and the result was stale. The third measurement showed that the duration is not in the model; since two layers with different durations reached the same final value, both came out matching. The two measurements complement each other. In drawing, what has to be written is a request; in animation, what has to be written is a value; if neither is written, the screen falls behind the model.

In practice two checks are enough. For every hand-drawn value, one makes sure that every path that writes the value also leaves the drawing request; the safest form is to attach an observer to the value itself. For every animated value, one verifies that the final value is written to the model layer before the animation is added. Both can be seen by looking at the code, but on the screen they appear only during testing, after a particular change. The measurement turns these two checks into one line each. This approach makes the two common causes of the screen falling behind the model catchable in code review; because what is looked for is a single line in both cases, the absence of that line is noticed before it shows on the screen. Moreover, these checks do not grow with the number of screens; every new screen is reviewed with the same two questions, and the answer is given each time by looking at the code.

The Bounding Measurement

Drawing and animation are about time, but the measure of this lesson does not cover time. The model layer carries the final value at once, the presentation layer shows an intermediate value; the model builds only the model layer. The duration of the animation is not a field in the model.

Suppose we write the same final value with two animations, a fast one and a slow one. In the SOURCE fragment the duration was a parameter; it has no counterpart in the model, so the two screens give the same result.

// main.swift — the animation duration is not in the model, two layers give the same result
let model = ["ringOpacity": "1"]
let fast = Screen("FastRing")
let slow = Screen("SlowRing")

fast.set("ringOpacity", "1")
slow.set("ringOpacity", "1")

let fastResult = check("short-duration", model: model, screen: fast, keys: ["ringOpacity"])
let slowResult = check("long-duration", model: model, screen: slow, keys: ["ringOpacity"])

countVerdicts(fastResult + slowResult)
short-duration | FastRing.ringOpacity: model=1 screen=1 -> matching
long-duration | SlowRing.ringOpacity: model=1 screen=1 -> matching
matching: 2
stale: 0
missing: 0
leftover: 0

Both rows are matching and the count is two matching. The difference in duration did not enter the output. This is a limit: the model says which value was written to the screen; it does not say how long it took to reach that value.

LC3. This measurement tests that two layers carry the same final value. No number was written for the frame time or the animation curve.

Summary

  • A hand-drawn view refreshes its drawing only when a redraw request arrives; if the request is not written, the screen stays stale.
  • Once the request is written, the drawing runs again and the screen is matching.
  • In a layer animation the model layer carries the final value at once and the presentation layer shows an intermediate value.
  • If an animation is added and the model layer is not written, the value returns to its old state and the screen stays stale.
  • The duration of the animation is not in the model; the same final value gives the same agreement with both durations.
  • This lesson’s agreement: stale.

Next Step

In this topic we built the interface imperatively: constraints, cells and drawing were managed with code. The next topic, bridging, will bring the imperative and the declarative interface together on the same screen. The hosting controller and wrapping will let the two sides pass values to each other; there too we will measure the screen’s agreement with the model using the same core.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close