Skip to content
academia.sh

Lesson 07 / 15

Constraint-Based Layout

A measurement of how a constraint constant that changes in the model but is never written to the on-screen constraint counts as stale, and a constraint that is never set up counts as missing.

Published Updated

In this lesson

  • Identifies the constant of the constraint as the value the screen takes from the model in constraint-based layout
  • Measures a value that changed in the model but was not written to the constraint as stale
  • Measures a field present in the model but never given a constraint as missing
  • Verifies that priority and ambiguity have no counterpart in the model and that the same constant gives the same agreement
Contents

The previous topic’s last lesson, Outlets and Actions, showed how a view on screen is connected to code. In this topic, what gets connected is not the view itself but its layout. In UIKit, layout is usually given by a constraint: a rule that states how an edge or the width of a view is positioned relative to something else. A constraint carries a constant, and in code that constant is a number. When the model changes that number, the constraint on screen does not learn the new number by itself.

The question of this lesson is not whether a calculation is correct but agreement: the model changed a spacing or a width, did the screen take that value. 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)")
    }
}

In the core, Screen keeps the values most recently written to the screen, one per key. verdict compares the model’s value with the screen’s value and returns one of four results: matching if both are the same, stale if they differ, missing if the model holds a value and the screen does not, and leftover if the screen holds a value and the model does not. The core is not a constraint solver; the model builds what the code writes to the screen, and it is not the framework itself.

The Constant a Constraint Carries

Consider the side padding and the width of an invoice card. Both are constants of constraints in code. When the card is created, the constraints are set up once and the constants take their values at that moment.

import UIKit

final class InvoiceCardView: UIView {
    let paddingConstraint = NSLayoutConstraint()
    let widthConstraint = NSLayoutConstraint()

    func apply(padding: CGFloat, width: CGFloat) {
        paddingConstraint.constant = padding
        widthConstraint.constant = width
    }
}

The apply(padding:width:) method here writes the two constants. The critical point is that the constants change only when this method is called. If the model later increases the padding and the method is not called, the constraint keeps carrying the old number.

In the measurement, the model carries two fields: sidePadding and cardWidth. The screen is a Screen that holds the constants written to the constraints. Three steps are given: the first setup, the model changing the padding, and the constant being updated.

// main.swift — the model changes the padding, it stays stale until the constraint is updated
let invoice = Screen("InvoiceCard")
var model = ["sidePadding": "16", "cardWidth": "320"]

invoice.set("sidePadding", "16")
invoice.set("cardWidth", "320")
let first = check("first-setup", model: model, screen: invoice, keys: ["sidePadding", "cardWidth"])

model["sidePadding"] = "24"
let second = check("padding-changed", model: model, screen: invoice, keys: ["sidePadding", "cardWidth"])

invoice.set("sidePadding", "24")
let third = check("constraint-updated", model: model, screen: invoice, keys: ["sidePadding", "cardWidth"])

countVerdicts(first + second + third)
first-setup | InvoiceCard.sidePadding: model=16 screen=16 -> matching
first-setup | InvoiceCard.cardWidth: model=320 screen=320 -> matching
padding-changed | InvoiceCard.sidePadding: model=24 screen=16 -> stale
padding-changed | InvoiceCard.cardWidth: model=320 screen=320 -> matching
constraint-updated | InvoiceCard.sidePadding: model=24 screen=24 -> matching
constraint-updated | InvoiceCard.cardWidth: model=320 screen=320 -> matching
matching: 5
stale: 1
missing: 0
leftover: 0

In the first-setup rows both fields are matching: the model and the screen carry the same number. In the padding-changed row, the model has 24 and the screen has 16 for sidePadding, and the result is stale. The screen still holds the old constant; cardWidth stays matching because it never changed. In the constraint-updated step, once the constant is written, both fields are matching again.

The count comes from six checks: five matching, one stale. The only stale row is the step where the model changed but the constraint was not written. No row is missing or leftover, because both keys exist on both sides.

LC1. This measurement compares the constant as text equality. Whether the constraint was activated, or whether it conflicts with another constraint, was not measured.

A Constraint That Is Never Set Up

Stale means the screen carries a value but it has aged. A quieter case is the model carrying a value while the constraint that would receive it on screen was never set up. Suppose a footnote area is added to the bottom of the card: the model states its height, but the code has not yet created that height constraint.

// main.swift — the model gives a height, it counts as missing until the constraint is set up
let card = Screen("InvoiceCard")
let model = ["sidePadding": "24", "footerHeight": "44"]

card.set("sidePadding", "24")
let before = check("footnote-added", model: model, screen: card, keys: ["sidePadding", "footerHeight"])

card.set("footerHeight", "44")
let after = check("constraint-set", model: model, screen: card, keys: ["sidePadding", "footerHeight"])

countVerdicts(before + after)
footnote-added | InvoiceCard.sidePadding: model=24 screen=24 -> matching
footnote-added | InvoiceCard.footerHeight: model=44 screen=- -> missing
constraint-set | InvoiceCard.sidePadding: model=24 screen=24 -> matching
constraint-set | InvoiceCard.footerHeight: model=44 screen=44 -> matching
matching: 3
stale: 0
missing: 1
leftover: 0

In the footnote-added step, footerHeight has 44 in the model, - on the screen, and the result is missing. The screen has no value at all for this key. Once the constraint is written in the constraint-set step, the row becomes matching. The total is three matching and one missing; there is no stale row, because a wrong number never appeared on screen, the number was absent.

Missing and stale look similar in code: in both, the user sees a wrong layout. The difference lies in the cause. Stale says the write step was skipped; missing says the setup step was never written.

LC2. This measurement is for the case where the footnote area is represented by a single key. Whether part of a group of constraints was set up and part was not was not measured.

How Constraints Are Actually Solved

In the measurement a constraint was reduced to a single number, but the framework itself solves constraints as a system of equations. The position and size of each view are determined by four values: the left edge, the top edge, the width and the height. Constraints write the relationships among these values. A set of constraints that determines these four values for a view in a single unambiguous way is complete. If it falls short, ambiguity arises: the framework can place the view in two or more valid positions, and which one it picks is not decided by a rule. If it has too much, a conflict arises: two constraints ask for different values of the same quantity, and one of them is broken at run time.

The priority decides which constraint gets broken. A priority is a number between 1 and 1000; 1000 is a required constraint, and the framework looks for a solution without breaking it. A constraint with a lower priority gives way when it clashes with higher-priority constraints. The framework writes the constraint it broke to the output, but the screen is still drawn; for this reason a conflict often remains only a log line and goes unnoticed.

import UIKit

func pinCard(_ card: UIView, in container: UIView) {
    card.translatesAutoresizingMaskIntoConstraints = false
    let width = card.widthAnchor.constraint(equalToConstant: 320)
    width.priority = .defaultHigh
    NSLayoutConstraint.activate([
        card.topAnchor.constraint(equalTo: container.topAnchor, constant: 16),
        card.centerXAnchor.constraint(equalTo: container.centerXAnchor),
        card.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 16),
        width
    ])
}

In this fragment the width is given priority 750. When the container is narrow and the leading-edge constraint stays required, the width constraint gives way; when the container is wide, it applies as 320. A single set of constraints therefore fits both cases. The point to watch is turning off translatesAutoresizingMaskIntoConstraints: if this value is left on for a view built in code, the framework also adds the view’s automatic constraints from the older layout style, and these conflict with the constraints you wrote.

Intrinsic Content Size and Resistance Values

A view can also carry a size without any constraints. A label asking for as much room as its text, or a button as much as its title, is an example; this request is called the intrinsic content size. For a view with an intrinsic content size, writing a width or height constraint is often unnecessary. Two values say what the view does when the space is too small or too large: content hugging states how strongly the view resists growing beyond its intrinsic content size, and compression resistance states how strongly it resists shrinking. When two labels sit side by side and the space is narrow, the label with the lower compression resistance gets shorter; if the two are equal, the framework cannot know which to pick, and ambiguity arises.

For this reason, for two variable-length texts placed side by side, one label must be given a clearly higher resistance. The FlexibleCard in the measurement showed that this flexibility is not reflected in the model: the resistance values are not in the model, yet they are decisive on screen.

Building the Constraint in Code or in the Editor

A constraint can be built in the visual interface editor or in code, and the two give the same result. The editor is short for structures that do not change: margins, fixed heights and alignments are drawn once and stored in the file. Code is useful for layouts that change at run time: a section appearing conditionally, a height that varies with the content, or a spacing that shifts with a user setting. If a constraint built in code is stored in a variable, its constant can be changed later and the framework solves the layout again in the next pass. The first two measurements of this lesson are about exactly this variable being updated. A simple rule is enough to decide: if the value will not change after setup, choose the editor; if it will change together with the model, choose code.

In mixed use, the only rule is that the constraints of a view come from a single source. If a constraint built in the editor must be changed in code, the constraint is connected to the code through an outlet, and the constant of the existing constraint is updated instead of adding a new one.

Common Mistakes

  • Setting up a constraint and forgetting to activate it. A constraint that has been created does nothing until it is activated. Activation is done with isActive or with NSLayoutConstraint.activate.
  • Setting up constraints for the same view in two separate places. If one comes from code and the other from the visual interface editor, a conflict arises and which one is broken can vary from run to run.
  • Forgetting to update the constant of the constraint when the model changes. The first measurement of this lesson is exactly this case: the constant carries the value it had when it was set up and does not follow the model on its own, even if the model changes later.
  • Leaving the constraints of a hidden view in place. Even when a view is hidden, its constraints stay and the gap is preserved; if you want the gap to close, a constraint change or a stack view is needed.
  • Tying the priority number to an estimated value. If two constraints with exactly the same priority clash, the result is undefined; the priorities must be clearly separated from one another.

Interpreting the Measured Result

The stale result of the first measurement is not a bug of the framework; it follows from the definition of the constraint. When a constraint is stored as a value and not as a relationship, writing the code that updates the relationship is the developer’s job. The missing result of the second measurement comes from the same root, but from the opposite direction: the model supplies a value and the constraint that would carry it on screen was never set up. The third measurement shows that priority is not in the model; the same constant gives the same agreement on both screens, and the difference in priority does not enter the output. The lesson to draw from the three results is this: when comparing the model with the screen, the value of a constraint can be seen, but its weight in the framework cannot.

The Bounding Measurement

Two properties of a constraint, priority and ambiguity, determine how the solver resolves clashes. A constraint with a low priority gives way when needed. In UIKit these two properties are read from code.

import UIKit

func makeWidthConstraint(for card: UIView, priority: UILayoutPriority) -> NSLayoutConstraint {
    let constraint = card.widthAnchor.constraint(equalToConstant: 320)
    constraint.priority = priority
    return constraint
}

The model has no such field. The model compares only the constant: whether the constraint is required or low-priority, the result is the same if the constant is the same. We show this with two screens that have the same constant; one is thought of as built with a required constraint, the other with a low-priority one. There is no key to carry the priority, so both write only cardWidth.

// main.swift — priority is not in the model, the same constant gives the same agreement on both screens
let model = ["cardWidth": "320"]
let required = Screen("RequiredCard")
let flexible = Screen("FlexibleCard")

required.set("cardWidth", "320")
flexible.set("cardWidth", "320")

let requiredResult = check("required", model: model, screen: required, keys: ["cardWidth"])
let flexibleResult = check("flexible", model: model, screen: flexible, keys: ["cardWidth"])

countVerdicts(requiredResult + flexibleResult)
required | RequiredCard.cardWidth: model=320 screen=320 -> matching
flexible | FlexibleCard.cardWidth: model=320 screen=320 -> matching
matching: 2
stale: 0
missing: 0
leftover: 0

Both rows are matching and the count is two matching. The difference in priority did not appear in the output. This is a limit: the constraint solver is not measured. The model compares the constant the constraint carries, not the size the screen gets after the solver. A constraint may have been overridden by a lower priority and the size on screen may still differ from the model; that difference is outside the scope of this lesson’s measurement.

LC3. This measurement is only for the equality of the constant. Priority and ambiguity were shown in the SOURCE fragment and did not enter the output.

Summary

  • In constraint-based layout, what the screen takes from the model is the constant of the constraint; when the model changes, the constant must be rewritten in code.
  • A value that changes in the model but is not written to the constraint is stale: the screen carries the old number.
  • A constraint that exists in the model but was never set up is missing: the screen has no value for that key.
  • Priority and ambiguity are not in the model; the same constant gives the same agreement in a required or a flexible constraint.
  • The model does not measure the constraint solver; it compares the constant, not the resolved size.
  • This lesson’s agreement: stale.

Next Step

Setting up constraints one by one becomes heavier as views multiply. In the next lesson a stack view takes the constraints on itself; what gets written is whether a subview is hidden or not. In that lesson the agreement is called by a new name: leftover.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close