Skip to content
academia.sh

Lesson 14 / 21

Presentation and Dismissal

Measuring that presentation state lives in the presenting view, that sheet content reads this state through a binding, and that the dismiss action is not a value.

Published Updated

In this lesson

  • Shows with a measurement that presentation state is a local state held in the presenting view
  • Separates the fact that sheet content gets the parent trigger when it reads the presentation key through a binding
  • Explains that the dismiss action is an action that comes from the environment, not a value
  • Measures that dismissal changes the presenter's state and therefore recomputes the presenter
Contents

A navigation stack adds a new screen to the stack and replaces the current screen; a presentation opens the new screen temporarily over the current one. A sheet, a full-screen cover or an alert does not exist on its own: a value says whether it is open, and this value, just like the navigation path, sits in a storage place. The trigger examined in this lesson is again local: the view that holds the presentation state is recomputed as the owner of the value. The lesson also looks at how dismissal works, because the dismiss action comes from another storage place, the environment.

The measurement uses the core file.

// Invalidation.swift — classifies which view a change recomputes and through which source
enum Storage {
    case state(owner: String)
    case model(object: String)
    case environment
}

struct ViewNode {
    let name: String
    let parent: String?
    let reads: Set<String>

    init(_ name: String, parent: String?, reads: Set<String>) {
        self.name = name
        self.parent = parent
        self.reads = reads
    }
}

struct Recompute {
    let view: String
    let cause: String
}

struct ViewTree {
    let nodes: [ViewNode]
    let storage: [String: Storage]

    func ancestors(of name: String) -> [String] {
        var found: [String] = []
        var current = nodes.first { $0.name == name }?.parent
        while let next = current {
            found.append(next)
            current = nodes.first { $0.name == next }?.parent
        }
        return found
    }

    func cause(of node: ViewNode, key: String) -> String {
        guard let kind = storage[key] else { return "out-of-scope" }
        switch kind {
        case .environment:
            return "environment"
        case .model:
            return "observed"
        case .state(let owner):
            if owner == node.name { return "local" }
            if ancestors(of: node.name).contains(owner) { return "parent" }
            return "off-rule"
        }
    }

    func change(_ label: String, key: String) -> [Recompute] {
        let hits = nodes.filter { $0.reads.contains(key) }
            .map { Recompute(view: $0.name, cause: cause(of: $0, key: key)) }
        let skipped = nodes.filter { !$0.reads.contains(key) }.map(\.name)
        let recomputed = hits.isEmpty ? "none" : hits.map { "\($0.view) \($0.cause)" }.joined(separator: ", ")
        let rest = skipped.isEmpty ? "none" : skipped.joined(separator: ",")
        print("\(label): \(key) changed | recomputed \(recomputed) | skipped \(rest)")
        return hits
    }
}

func countCauses(_ recomputes: [Recompute]) {
    for cause in ["local", "parent", "observed", "environment"] {
        print("\(cause): \(recomputes.filter { $0.cause == cause }.count)")
    }
}

The core has no type specific to presentation. Presentation state is a Bool or an optional value, and in the model it is written as a state key; if the sheet’s content reads this key, it carries it in its reads set.

Presentation State Lives in the Presenter

A sheet is opened with two parts: a Bool held in the presenting view and the .sheet(isPresented:content:) modifier. The modifier takes the binding, builds a view from the content closure when the value is true, and removes it when the value goes back to false. In the source fragment below the presenter is InvoiceScreen.

import SwiftUI

struct InvoiceScreen: View {
    @State private var isEditorPresented = false

    var body: some View {
        VStack {
            InvoiceHeader()
            Button("Edit") { isEditorPresented = true }
        }
        .sheet(isPresented: $isEditorPresented) {
            InvoiceEditor()
        }
    }
}

struct InvoiceHeader: View {
    var body: some View {
        Text("Invoice 2041")
    }
}

The state is in InvoiceScreen, and the modifier is written in the body of the same view. InvoiceHeader is not tied to the presentation at all. InvoiceEditor is built inside the closure. The presenter and the header are in the tree; while the sheet is open, the content enters the tree as well. First we measure the presentation itself, without the content: opening and closing concern the owner, not the writer.

// main.swift — presentation state lives in the presenter, opening and closing produce the same trigger
let storage: [String: Storage] = [
    "isEditorPresented": .state(owner: "InvoiceScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("InvoiceScreen", parent: nil, reads: ["isEditorPresented"]),
    ViewNode("InvoiceHeader", parent: "InvoiceScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("sheet-opened", key: "isEditorPresented"),
    screen.change("sheet-closed", key: "isEditorPresented"),
].flatMap { $0 }

countCauses(recomputes)
sheet-opened: isEditorPresented changed | recomputed InvoiceScreen local | skipped InvoiceHeader
sheet-closed: isEditorPresented changed | recomputed InvoiceScreen local | skipped InvoiceHeader
local: 2
parent: 0
observed: 0
environment: 0

Two changes, two rows, and both recomputed only the presenter. InvoiceHeader was skipped both times: it does not read the presentation key. Presentation state is the view’s own state and produces the local trigger. The count is local two, parent, observed and environment zero.

This is the plain form of the rule of the second lesson and of local state. An open sheet expands the tree, but the expansion does not count as a recomputation; when the sheet’s content is built it computes its first body, and that is a construction.

SN16. This measurement covers two changes and a single Bool key. Presentation with an optional type (a sheet that uses item:) and the case where several presentations are used together in the same view were not measured. The time the sheet takes to appear on screen was not measured either.

Sheet Content Reads Through a Binding

It is common for sheet content to read the presentation state: a close button, a title that changes while the presentation is open. For this the content takes the state as a binding, so that it both reads and writes. This is the parent trigger of the second lesson: the owner of the state is the presenter, and the reader is the sheet’s content.

import SwiftUI

struct InvoiceEditor: View {
    @Binding var isPresented: Bool

    var body: some View {
        VStack {
            Text(isPresented ? "Editing" : "Closing")
            Button("Done") { isPresented = false }
        }
    }
}

The presenting view now builds the content as InvoiceEditor(isPresented: $isEditorPresented). In the measurement the tree has three views: the presenter, the header and the content. The same two changes are applied.

// main.swift — the sheet content reads the presentation key through a binding
let storage: [String: Storage] = [
    "isEditorPresented": .state(owner: "InvoiceScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("InvoiceScreen", parent: nil, reads: ["isEditorPresented"]),
    ViewNode("InvoiceHeader", parent: "InvoiceScreen", reads: []),
    ViewNode("InvoiceEditor", parent: "InvoiceScreen", reads: ["isEditorPresented"]),
], storage: storage)

let recomputes = [
    screen.change("sheet-opened", key: "isEditorPresented"),
    screen.change("sheet-closed", key: "isEditorPresented"),
].flatMap { $0 }

countCauses(recomputes)
sheet-opened: isEditorPresented changed | recomputed InvoiceScreen local, InvoiceEditor parent | skipped InvoiceHeader
sheet-closed: isEditorPresented changed | recomputed InvoiceScreen local, InvoiceEditor parent | skipped InvoiceHeader
local: 2
parent: 2
observed: 0
environment: 0

In both changes the presenter is recomputed with the local trigger and the content with the parent trigger; the header is skipped. The count is local two, parent two. That the content stands in the tree as a child of the presenter is the condition of the parent trigger: the owner is an ancestor of the reader.

One detail needs attention here. The sheet’s content is modeled as a child of the presenter, because the presenter builds the content closure. Even though the sheet appears in a separate layer on screen, in terms of ownership of the state it sits under the presenter in the tree.

SN17. This measurement is for a single tree in which the content is a direct child of the presenter. The case where the content passes the binding on to other views several levels down, and the case where the sheet carries a navigation stack of its own, were not measured.

The Bounding Measurement

There are two ways to close a sheet: the content writes false to the presentation binding, or it calls the dismiss action (dismiss) that comes from the environment. The second is read with @Environment(\.dismiss). It is not a value but a callable action; calling it tells the framework “close the state that presents me”, and the framework writes the presenter’s state. The action by itself produces no change on its own. Reading the action from the environment does not mean waiting for an environment value to change, because the action itself is constant.

import SwiftUI

struct InvoiceEditor: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Close") { dismiss() }
    }
}

The measurement models what the dismiss call actually does. dismiss takes its place in the tree as an environment key that the content reads, and it is not changed, because the action itself is constant; what changes with the call is the presentation key.

// main.swift — dismiss is an action, not a value; closing changes the presenter's state
let storage: [String: Storage] = [
    "dismiss": .environment,
    "isEditorPresented": .state(owner: "InvoiceScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("InvoiceScreen", parent: nil, reads: ["isEditorPresented"]),
    ViewNode("InvoiceEditor", parent: "InvoiceScreen", reads: ["dismiss"]),
], storage: storage)

let recomputes = [
    screen.change("dismiss-called", key: "isEditorPresented"),
].flatMap { $0 }

countCauses(recomputes)
dismiss-called: isEditorPresented changed | recomputed InvoiceScreen local | skipped InvoiceEditor
local: 1
parent: 0
observed: 0
environment: 0

The single row is the dismiss call changing the presentation key. This recomputes only the presenter with the local trigger; the content is skipped because it does not read this key. Because the dismiss key does not change, the environment counter is zero. The count is local one.

This row gives the result of the lesson. Reading the dismiss action does not tie the content to the presentation state; when the call is made, what changes is not the environment but the presenter’s state. Because the action comes from the environment, it takes its place in the tree as an environment key, but the result of the call is seen in the presenter. If the content making the call does not read the presentation state, it does not take part in the recomputation of the closing either.

SN18. The measurement builds dismiss as an environment key that does not change; whether the action handle changes in the framework was not measured. The order in which the dismiss call writes the presentation state, and waiting for the closing animation to finish, were not measured.

Summary

  • Presentation state is a value held in the presenting view; opening and closing produce the same trigger.
  • The presenter is recomputed as the owner of the state with the local trigger, and the sheet content with the parent trigger if it reads through a binding.
  • Sibling views unrelated to the presentation are skipped.
  • dismiss is an action read from the environment; calling it changes the presenter’s state, and the action itself is not a value.
  • This lesson’s trigger: local.

Next Step

In this lesson the presentation was managed with a single Bool, and its owner was the presenting view. In most applications, however, a screen is divided into several sections: a tab bar and a toolbar say which section will be shown and which actions will be offered. In the next lesson the selection sits at the root; the content of the sections reaches it through a binding, and whether it reads it is measured separately.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close