Skip to content
academia.sh

Lesson 16 / 21

Gestures

Measuring that the temporary value during a drag recomputes only the view that carries the gesture, while the final position written when the gesture ends reaches its readers through the parent trigger.

Published Updated

In this lesson

  • Separates the gesture's temporary value, held in gesture state, from its final result, held in state
  • Measures that a change during a drag recomputes only the view that carries the gesture
  • Measures that the position written when the gesture ends reaches the reading siblings through the parent trigger
  • Shows that the gesture state returning to its initial value is counted as a separate change
Contents

The previous topic looked at the path by which a value is bound to a view; users usually change these values with a gesture. The question in this lesson does not change: what triggered this recomputation? This lesson’s trigger is local. In the course’s opening lesson, six changes produced eight recomputations; two belonged to the local trigger, two to parent, two to observed and two to environment. Now we will count which of these four categories a single drag uses.

A gesture is a declarative definition that turns the user’s touch on the screen into a value: tap, long press, drag and their combinations are attached to a view as a modifier in the same way. One property of the drag determines the measurement. While the finger is on the screen, the position changes many times per second, but once the finger lifts, none of these intermediate values has to be permanent. For this the framework provides a separate property wrapper: @GestureState. The value is updated while the gesture runs, and when the gesture ends the value returns to its initial value by itself. The permanent result is written, in the gesture’s end callback, to an ordinary state value.

The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.

// 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 Two Values of a Drag

The fragment below drags a card. The view that draws the card keeps the temporary offset in its own gesture state. The permanent position lives in a parent view and is passed to the card through a binding; a label also reads the position.

import SwiftUI

struct BoardScreen: View {
    @State private var position = CGSize.zero

    var body: some View {
        VStack {
            DragCard(position: $position)
            PositionLabel(position: position)
            HintText()
        }
    }
}

struct DragCard: View {
    @Binding var position: CGSize
    @GestureState private var dragOffset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .offset(
                x: position.width + dragOffset.width,
                y: position.height + dragOffset.height
            )
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation
                    }
                    .onEnded { value in
                        position.width += value.translation.width
                        position.height += value.translation.height
                    }
            )
    }
}

struct PositionLabel: View {
    let position: CGSize

    var body: some View {
        Text("\(Int(position.width)), \(Int(position.height))")
    }
}

struct HintText: View {
    var body: some View {
        Text("Drag the card")
    }
}

This fragment does not compile and produces no output; because it imports the platform framework, it stands as source only. We will read the tree from it and rebuild it with plain values. DragCard reads two values: dragOffset is its own gesture state, and position is the binding that comes from the parent. PositionLabel reads only position. HintText reads neither. BoardScreen owns the position and passes it in its body both as a binding and as a value; that is, its body reads the position too.

Before measuring, we state what the model is. The model builds the framework’s dependency-tracking rule; it is not the framework itself: a view is recomputed when a value it reads changes, and skipped when a value it does not read changes. How many times the framework calls the body, or when it draws, is not measured here.

The core changes nothing; given a key, it finds the views that read it and classifies the trigger for each. Now we build the tree that carries the same names as the types in the source fragment above, and write three changes: dragOffset while the drag runs, position when the gesture ends, and the gesture state’s return to its initial value.

// main.swift — three changes during and after the drag
let storage: [String: Storage] = [
    "position": .state(owner: "BoardScreen"),
    "dragOffset": .state(owner: "DragCard"),
]

let screen = ViewTree(nodes: [
    ViewNode("BoardScreen", parent: nil, reads: ["position"]),
    ViewNode("DragCard", parent: "BoardScreen", reads: ["position", "dragOffset"]),
    ViewNode("PositionLabel", parent: "BoardScreen", reads: ["position"]),
    ViewNode("HintText", parent: "BoardScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("drag-in-progress", key: "dragOffset"),
    screen.change("drag-ended-position", key: "position"),
    screen.change("drag-ended-offset", key: "dragOffset"),
].flatMap { $0 }

countCauses(recomputes)
drag-in-progress: dragOffset changed | recomputed DragCard local | skipped BoardScreen,PositionLabel,HintText
drag-ended-position: position changed | recomputed BoardScreen local, DragCard parent, PositionLabel parent | skipped HintText
drag-ended-offset: dragOffset changed | recomputed DragCard local | skipped BoardScreen,PositionLabel,HintText
local: 3
parent: 2
observed: 0
environment: 0

While the drag runs, a single view is recomputed: the card itself, because dragOffset is its gesture state and only its body reads it. Its two siblings and its parent are skipped. This is why dragging is cheap: each time the finger moves, the body of the label or of the hint text is not recomputed.

IP1. This measurement holds only for a tree in which the owner of dragOffset is DragCard. A layout that moved the gesture state up to a parent view would give different counts; that layout was not measured.

IP2. This measurement represents a single change during the drag. How many intermediate values the finger produces in a second, and whether the framework coalesces them, was not measured.

The Permanent Position Is Written to the Parent

The second row tells a different story. When the gesture ends, the permanent position is written to position, and the owner of this value is BoardScreen. The owner is itself recomputed, so the trigger is local. DragCard, which reads the value through a binding, and PositionLabel, which takes the value as a parameter, sit below the owner, so for them the trigger is parent: they did not make the change themselves, a value of a view above them changed. HintText is skipped again, because it reads nothing.

The third row is the gesture state’s return to its initial value. The framework does this for us, but the model does not: the return was added by the author as a separate change in the model. The card is recomputed once more in this change, and the trigger is again local. This is why the final count gives three recomputations for the local trigger and two for the parent trigger: of the three local ones, two belong to the card and one to the screen; both parent ones come from the position change. The observed and environment counts are zero, because a drag never uses these two channels. The difference between this distribution and the 2/2/2/2 of the opening lesson comes from the kind of change: a single interaction usually stays within one or two channels.

The practical consequence of this distinction is as follows. If the value that moves with the finger is held in gesture state, siblings that do not read it are unaffected for the whole drag. If the same value were updated directly on position, every intermediate value would also recompute the label. Separating the two values is a design that fits the tool the framework provides, and the measurement shows this with numbers.

IP3. This measurement holds for a tree in which DragCard, PositionLabel and HintText are direct children of BoardScreen. A layout in which the readers sit deeper in the tree was not measured.

IP4. This measurement assumes that the value of position is written once when the gesture ends. At what moment, and on which thread, the end callback runs was not measured.

Taps and Combined Gestures

The drag is only one member of this family. Tap, long press and drag are separate gesture definitions; they can be attached to a view one by one or combined. Combination has three forms: one defines gestures that run at the same time, one defines gestures that run in sequence, and the other defines gestures where only one succeeds, with priority given to the first. The fragment below sets up a sequence that asks for a long press first and then a drag, and also binds a tap to a state value.

import SwiftUI

struct CardRow: View {
    @State private var isPinned = false

    var body: some View {
        Text("Card")
            .onTapGesture {
                isPinned.toggle()
            }
            .gesture(
                LongPressGesture()
                    .sequenced(before: DragGesture())
            )
    }
}

This fragment has no new trigger category. The tap closure writes to an ordinary state value; because the owner of the value is CardRow, the change is local, and if no other view reads isPinned, only the row itself is recomputed. The gesture itself is not a trigger: the trigger is the value written at the end of the gesture or updated during it. This distinction is the main idea of the lesson, and it explains why the measurement was made with the keys dragOffset and position. The model has no key called “drag”; the keys are the storage places that the gesture touches.

In a sequenced combination, the second gesture does not start until the first succeeds. This behavior belongs to the framework’s gesture recognition rules and is not measured in this lesson; the model asks only which value is written at the end of the combination.

The Bounding Measurement

We show in a separate block what the model cannot measure. Not every piece of information a gesture offers is bound to a storage place: a value such as the drag velocity is read only inside the callback and is never held in any state. The model has no storage record for such a value. If we define a reading view and leave out the record, the core cannot classify the trigger.

// main.swift — a key with no storage record
let storage: [String: Storage] = [
    "position": .state(owner: "BoardScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("BoardScreen", parent: nil, reads: ["position"]),
    ViewNode("DragCard", parent: "BoardScreen", reads: ["position", "velocity"]),
], storage: storage)

let recomputes = screen.change("velocity-read", key: "velocity")
print(recomputes.count)
velocity-read: velocity changed | recomputed DragCard out-of-scope | skipped BoardScreen
1

The core writes out-of-scope, and this is not a trigger category; it is the model’s way of saying “I do not know where you read this value from.” The lesson’s counts hold only for changes that fall into one of the four categories. When the gesture state returns to its initial value is in the same class: the model counts the return because the author added it, and does not claim the framework’s timing. Whether the return happens before or after the end callback was not measured.

Summary

  • A gesture is a declarative definition that turns the user’s touch on the screen into a value; the temporary value is held in gesture state, the permanent result in state.
  • Gesture state that changes during a drag recomputes only the view that reads it; the siblings are skipped.
  • The position written when the gesture ends reaches its owner through the local trigger, and its children that read it through a binding or a parameter through the parent trigger.
  • The gesture state’s return to its initial value is a separate local change in the model; the framework’s timing is not measured.
  • This lesson’s trigger: local.

Next Step

At the end of a drag the position changes and the view moves to its new place; whether this move is abrupt or smooth is a separate question. In the next lesson we will measure whether a change wrapped in an animation differs, from the trigger’s point of view, from the same change left unwrapped.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close