Lesson 08 / 15
Stack Views
A measurement of how a badge removed from the model but not hidden in the stack view counts as leftover, how it counts as matching once the hiding is written, and how hiding is built into the model as a visibility field.
Published Updated
In this lesson
- Identifies that a stack view arranges its subviews and that a hidden subview drops out of the layout
- Measures a field removed from the model but not hidden on screen as leftover
- Shows that a hidden field absent on both sides is out-of-scope and not counted
- Verifies that hiding and removing write the same visibility field in the model and that the size difference is not measured
Contents
In the previous lesson, Constraint-Based Layout, we measured that writing the constant of a constraint by hand lets the screen fall behind the model. Setting up constraints one by one becomes heavier as views multiply. A stack view lightens this load: it arranges its subviews along an axis and calculates the spacing between them itself. The measure of this lesson falls somewhere else. In a stack view, removing something from the screen means hiding it, and it is the code that does the hiding.
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. The model builds what the code writes to the screen; it is not the framework itself.
A Subview That Drops Out of the Layout
Consider an order row: an order title on top, and next to it a badge that marks new orders. The two sit side by side in a stack view. When the badge is removed from the model, that is, when the order is no longer considered new, the badge’s view keeps standing in the stack.
import UIKit
final class OrderRowView: UIView {
let titleLabel = UILabel()
let badgeLabel = UILabel()
let rowStack = UIStackView()
func configure(title: String, badge: String?) {
titleLabel.text = title
badgeLabel.text = badge
}
}
When there is no badge, configure(title:badge:) does not empty the text, it only assigns the new value; and badgeLabel.isHidden is not set either. For this reason, when the badge is removed from the model, the old text stays on screen. The measurement has three steps: the order is loaded, the badge is removed from the model, and then the screen hides the badge.
// main.swift — a badge removed from the model is leftover if not hidden, out-of-scope once hidden
let row = Screen("OrderRow")
var model = ["title": "Order 1042", "badge": "New"]
row.set("title", "Order 1042")
row.set("badge", "New")
let loaded = check("order-loaded", model: model, screen: row, keys: ["title", "badge"])
model["badge"] = nil
let removed = check("badge-removed", model: model, screen: row, keys: ["title", "badge"])
row.clear("badge")
let hidden = check("badge-hidden", model: model, screen: row, keys: ["title", "badge"])
countVerdicts(loaded + removed + hidden)
order-loaded | OrderRow.title: model=Order 1042 screen=Order 1042 -> matching order-loaded | OrderRow.badge: model=New screen=New -> matching badge-removed | OrderRow.title: model=Order 1042 screen=Order 1042 -> matching badge-removed | OrderRow.badge: model=- screen=New -> leftover badge-hidden | OrderRow.title: model=Order 1042 screen=Order 1042 -> matching badge-hidden | OrderRow.badge: model=- screen=- -> out-of-scope matching: 4 stale: 0 missing: 0 leftover: 1
In the order-loaded step both fields are matching. In the badge-removed step title is still matching; for badge the model is -, the screen is New and the result is leftover. A value is left on screen that no longer has a counterpart in the model. In the badge-hidden step, once the screen is also -, the row prints out-of-scope: a field absent on both sides is counted as neither matching nor leftover.
The count of the six rows covers five: four matching, one leftover. The sixth row is out-of-scope, so it enters no count; none of the four names in the count includes it.
LC1. This measurement represents whether the badge exists on screen through the value of the key. Whether the stack view actually keeps the subview in the tree was not measured.
Hiding Is a Field
Hiding is a value written to the isHidden property of the subview. The stack view takes a hidden subview out of the layout and the remaining views close the gap. In other words, the screen’s information about whether the badge is visible stands as a separate value, and the model can hold it as a visibility field.
import UIKit
extension OrderRowView {
func showBadge(_ isVisible: Bool) {
badgeLabel.isHidden = !isVisible
}
}
In the model, the badgeVisible key carries the value yes or no. When the model wants the badge hidden and the screen does not receive that call, the two sides part ways.
// main.swift — visibility field: the model says hide, it stays stale if the screen does not hide
let row = Screen("OrderRow")
var model = ["badgeVisible": "yes"]
row.set("badgeVisible", "yes")
let shown = check("badge-visible", model: model, screen: row, keys: ["badgeVisible"])
model["badgeVisible"] = "no"
let stale = check("model-hid", model: model, screen: row, keys: ["badgeVisible"])
row.set("badgeVisible", "no")
let synced = check("screen-hid", model: model, screen: row, keys: ["badgeVisible"])
countVerdicts(shown + stale + synced)
badge-visible | OrderRow.badgeVisible: model=yes screen=yes -> matching model-hid | OrderRow.badgeVisible: model=no screen=yes -> stale screen-hid | OrderRow.badgeVisible: model=no screen=no -> matching matching: 2 stale: 1 missing: 0 leftover: 0
In the second row the model is no, the screen is yes and the result is stale. When visibility stands as a value, skipping it yields stale, not leftover: both sides hold a value, only the screen’s is old. The same subject was measured in two forms and two separate categories came out. When the field is removed from the model entirely it is leftover, when its value changes it is stale.
LC2. This measurement represents visibility with the texts yes and no. It was assumed that the isHidden value is a Bool and that the text comparison reflects what the code wrote to the screen.
Distribution in a Stack View
A stack view is a container that lines up its subviews along an axis and calculates the gaps between them itself. The axis is chosen with axis: horizontal or vertical. How the subviews spread along the axis is decided by the distribution, and the placement in the direction perpendicular to the axis is decided by the alignment. This distinction matters, because the two settings are independent of each other and this is the point that is mixed up most often.
Distribution has five values. fill fills the space by stretching one of the subviews; which one stretches is told by the hugging resistances. fillEqually gives all of them equal size. fillProportionally distributes in proportion to the intrinsic content sizes. equalSpacing equalizes the gaps in between without touching the sizes. equalCentering equalizes the distance between the centers of the views. Alignment, in turn, chooses the position in the perpendicular direction with values such as fill, leading, center and trailing.
import UIKit
func makeRow(title: String, badge: UIView) -> UIStackView {
let label = UILabel()
label.text = title
let row = UIStackView(arrangedSubviews: [label, badge])
row.axis = .horizontal
row.alignment = .center
row.distribution = .fill
row.spacing = 8
return row
}
Since the distribution in this row is fill, one of the views between the label and the badge has to stretch. If it is not stated explicitly which one will stretch, the framework looks at the intrinsic content size and the resistances. To keep the badge at a fixed size, raising the badge’s hugging resistance is enough.
Hiding and Removing Are Not the Same
The most useful behavior of a stack view is that it closes the place of a hidden subview. A view whose isHidden value is set to true leaves the layout and its neighbors fill the gap. This behavior is specific to the stack view; a view hidden outside a stack keeps its place.
Removing is a separate operation. The removeArrangedSubview call takes the view out of the distribution only; the view is still a subview of the stack view and continues to be drawn. To remove it from the screen for real, removeFromSuperview is needed. This difference is the subject of the third measurement of this lesson: hiding and removing write the same visibility value in the model, but they give different results in terms of size and layout.
import UIKit
func hide(_ badge: UIView, in row: UIStackView) {
badge.isHidden = true
}
func remove(_ badge: UIView, from row: UIStackView) {
row.removeArrangedSubview(badge)
badge.removeFromSuperview()
}
The second function takes two steps because only the first leaves the view on screen. Only the second also takes it out of memory; if the view needs to be shown again later, hiding is cheaper.
Stack View or Constraints
A stack view is not the answer to every layout problem. Where subviews line up along a single axis, that is, in forms, rows, button bars and label lists, a stack view reduces dozens of constraints to a single setting. If views overlap, if a view is positioned relative to the corner of another view, or if proportions are tied to each other, constraints are more direct. Combining the two is also common: the stack view itself is positioned with constraints, and the ordering inside it is left to the stack view.
This choice matters for the measurement too. Because the stack view resolves the layout itself, the match between the model and the screen comes down to the existence and visibility of the subview. In constraint-based layout, each constant has to be updated separately. Building the same screen in two ways also changes the number of values that may stay stale.
Common Mistakes
- Adding directly instead of through the arranged subviews of the stack view. A view added to a stack view directly with
addSubviewdoes not take part in the distribution. - Confusing hiding with removing. If only
removeArrangedSubviewis called when the badge is removed from the model, the badge stays on screen; the leftover row in the first measurement of this lesson is this case. - Confusing distribution with alignment. A person who wants to center the views horizontally in a vertical stack changes the
distributionvalue and gets no result. - Nesting stack views deeper than necessary. Each layer means one more constraint resolution and makes debugging harder.
- Writing the gap into the subview’s own constraints. A stack view does this with
spacing; if it is also written as a constraint, a conflict arises.
Interpreting the Measured Result
In the first measurement the badge was absent from the model and still on screen, and the row counted as leftover. The stack view does not clear this by itself, because the container does not know which subview matches the model. A badge goes away in two ways: it is hidden or it is removed. When the badge was hidden or removed, the row became out-of-scope, because there was no value on either side. In the third measurement hiding and removing wrote the same visibility value and both came out matching: the measuring axis is concerned with whether a value is present on screen, not with whether the place of the view closes. The second measurement showed another break in the visibility field: if the model said hide and the screen did not hide, the row is stale. Reading the three measurements together gives a single conclusion about the stack view: the container resolves the layout, but the developer ensures the match with the model.
In practice this requires two separate checks on a screen that uses a stack view. First, for every item in the model, it is checked whether the screen has a subview; if one is missing, it has not been added. Then, for every subview on the screen, a counterpart is looked for in the model; a view with no counterpart, that is, one left over, has not been hidden or removed. During removal too, taking a view out of the distribution and removing it from the view tree are separate steps, and unless both are done the view stays on screen. The stack view does not do this work for us, because the container does not know which subview belongs to which model item. Collecting these two checks in a helper function ensures that the same check runs on every update of the screen and noticeably reduces the number of forgotten cases; the rule is written once and not remembered again on every screen. Moreover, if this check is run after every update for conditionally appearing items such as the badge, stale and leftover rows become visible the moment they first appear.
The Bounding Measurement
We can take a subview out of a stack in two ways: we can hide it or we can remove it from the stack. The gap each leaves in the layout may not be the same; a hidden view does not reserve room in the stack’s arrangement but is still in the tree, while a removed one has left the tree. A hidden subview drops out of the stack layout; this is a consequence of the layout, and the model does not measure it.
The model builds only visibility as a field. Both ways write the same value to the same field, so no claim is made about the size difference.
// main.swift — hiding and removing write the same visibility value, the size difference is not measured
let model = ["badgeVisible": "no"]
let hiddenRow = Screen("HiddenRow")
let removedRow = Screen("RemovedRow")
hiddenRow.set("badgeVisible", "no")
removedRow.set("badgeVisible", "no")
let hiddenResult = check("hidden", model: model, screen: hiddenRow, keys: ["badgeVisible"])
let removedResult = check("removed", model: model, screen: removedRow, keys: ["badgeVisible"])
countVerdicts(hiddenResult + removedResult)
hidden | HiddenRow.badgeVisible: model=no screen=no -> matching removed | RemovedRow.badgeVisible: model=no screen=no -> matching matching: 2 stale: 0 missing: 0 leftover: 0
The two rows give exactly the same result: matching. The difference that hiding and removing leave in the layout did not enter the output, because the model has no size field. This is a limit: the model does not say whether a subview dropped out of the layout, it says whether the visibility field was written on screen.
LC3. This measurement tests that two screens carry the same value. No number was written for how the stack view closes the gap.
Summary
- A stack view arranges subviews; a hidden subview drops out of the layout, and it is the code that writes the hiding.
- A field removed from the model but not hidden on screen results in leftover.
- A field absent on both sides is out-of-scope and enters no count.
- When visibility is held as a field, a skipped hiding gives stale, and a field removed entirely gives leftover.
- Hiding and removing write the same visibility value in the model; the size difference is not measured.
- This lesson’s agreement: leftover.
Next Step
Stack views arrange subviews once. A table view, on the other hand, uses the same cell many times, and a reused cell can carry the trace of the previous row. In the next lesson the reuse of the cell and the value left over from the old row will be measured.
To keep your progress and take notes, Log in
My notes
Log in to take notes.