Skip to content
academia.sh

Lesson 20 / 21

Localization

Showing that the locale and layout direction cause recomputation through the environment trigger, that the model's date property does so through the observed trigger, and that formatting is not measured.

Published Updated

In this lesson

  • Measures that the locale and the layout direction are read as environment values
  • Measures that the model's date property recomputes the reading view through the observed trigger
  • Shows that formatting is a pure function and that the model counts only the view that reads the locale
Contents

In the previous lesson we measured that the user’s system preferences recompute the views that read them through the environment trigger. The user’s language and region are a preference of the same kind. The question is again the same: what triggered this recomputation? The trigger examined in this lesson is environment; an observed example sits beside it.

Localization covers three separate subjects. The first is text: the translated counterpart of a phrase in the interface is shown in the user’s language. The second is format: dates, numbers and currencies are written according to the habit of the user’s region; the same day appears in one place in day-month order and in another in month-day order. The third is layout direction: some languages are written from right to left, and the interface layout is mirrored accordingly. The framework offers the locale for the first two and the layout direction for the third as environment values: @Environment(\.locale) and @Environment(\.layoutDirection). Like the text size in the previous lesson, these two are values that come from outside the view.

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)")
    }
}

Locale, Layout Direction and Date

The fragment below is an event screen. The event’s start date lives in an observable object. The header reads the locale. The date label reads both the date and the locale, because it formats the date according to that locale. The action row reads the layout direction and chooses the direction of an arrow mark accordingly. The footer note is raw text; localization is not applied to it.

import SwiftUI

@Observable
final class EventStore {
    var startDate = Date()
    var title = ""
}

struct EventScreen: View {
    let store: EventStore

    var body: some View {
        VStack {
            EventHeader()
            DateLabel(store: store)
            ActionRow()
            FooterNote()
        }
    }
}

struct EventHeader: View {
    @Environment(\.locale) private var locale

    var body: some View {
        Text(locale.identifier)
    }
}

struct DateLabel: View {
    let store: EventStore
    @Environment(\.locale) private var locale

    var body: some View {
        Text(store.startDate.formatted(.dateTime.day().month().locale(locale)))
    }
}

struct ActionRow: View {
    @Environment(\.layoutDirection) private var layoutDirection

    var body: some View {
        Text(layoutDirection == .rightToLeft ? "<" : ">")
    }
}

struct FooterNote: View {
    var body: some View {
        Text(verbatim: "v1")
    }
}

This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. EventScreen reads no value directly; it only builds its children. EventHeader and DateLabel read the locale, and DateLabel also reads store.startDate. ActionRow reads the layout direction. FooterNote reads no value. The model builds the framework’s dependency-tracking rule; it is not the framework itself.

We write three changes: the user changes the language, the layout direction turns right to left, and the event’s date changes. Changing the environment values is, in this lesson, a system setting, not a modifier.

// main.swift — locale, layout direction and date
let storage: [String: Storage] = [
    "startDate": .model(object: "EventStore"),
    "locale": .environment,
    "layoutDirection": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("EventScreen", parent: nil, reads: []),
    ViewNode("EventHeader", parent: "EventScreen", reads: ["locale"]),
    ViewNode("DateLabel", parent: "EventScreen", reads: ["startDate", "locale"]),
    ViewNode("ActionRow", parent: "EventScreen", reads: ["layoutDirection"]),
    ViewNode("FooterNote", parent: "EventScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("language-changed", key: "locale"),
    screen.change("direction-changed", key: "layoutDirection"),
    screen.change("date-changed", key: "startDate"),
].flatMap { $0 }

countCauses(recomputes)
language-changed: locale changed | recomputed EventHeader environment, DateLabel environment | skipped EventScreen,ActionRow,FooterNote
direction-changed: layoutDirection changed | recomputed ActionRow environment | skipped EventScreen,EventHeader,DateLabel,FooterNote
date-changed: startDate changed | recomputed DateLabel observed | skipped EventScreen,EventHeader,ActionRow,FooterNote
local: 0
parent: 0
observed: 1
environment: 3

When the language changes, the two views that read the locale, the header and the date label, are recomputed through the environment trigger. The screen, the action row and the footer note are skipped. When the layout direction changes, only the action row is recomputed. When the date changes, only the date label is computed, and the trigger is observed: the date is a property of a model object. The same label can be recomputed through two separate channels because it reads values from both the environment and the model; the changed key determines which channel runs.

The final count gives three environment and one observed recomputation. The local and parent counts are zero, because in this tree no view receives a value that changes from a view above it, and no local state changes. The weight of the environment count comes from localization resting on system preferences.

IP17. This measurement holds only for a tree in which DateLabel reads the locale explicitly with @Environment. The framework resolving text constants according to the locale is a separate job and was not written as a read in the tree.

IP18. This measurement represents the moment the locale changes with a single label. Whether the app is restarted when the language changes was not measured.

What the Layout Direction Changes

In a language written from right to left, the interface layout is mirrored: the start of the line is on the right, and the forward and back arrows change direction. The framework does this itself with containers such as stacks and lists; but direction-dependent content such as an arrow mark is the view’s own decision, and the layoutDirection environment value is read for this decision. When the direction changes, the view that reads this value is recomputed.

Two kinds of direction dependence must not be confused here. The part where the framework applies the mirroring itself does not require the view to read a value. The view choosing its own content according to the direction is a read, and therefore a dependency. The measurement counts only the second. Which views the first one recomputes in the tree is the framework’s internal business and was not measured.

IP19. This measurement represents the change of only one of the two values of the layout direction. The measures of the mirrored layout, the alignment and the view order were not measured.

IP20. This measurement does not say in what format the date label is written on the screen. How the date is written was not measured.

The Bounding Measurement

Formatting is a function whose inputs are a value and a locale: in the function built in this lesson, the same value and the same locale always give the same text. Real calendar formatting depends on the calendar and the time zone as well as the locale; these were not measured. This function is not a reason for a recomputation; the reason is the change of the locale that is an input to the function. Real date and number formats rest on locale data, and this data is tied to the device, so they are not measured in this lesson. The block below shows with a small example that formatting is a function, and then tests that the model counts only the view that reads the locale.

// main.swift — formatting is a pure function
func dayLabel(day: Int, month: Int, monthFirst: Bool) -> String {
    monthFirst ? "\(month)/\(day)" : "\(day).\(month)"
}

print(dayLabel(day: 5, month: 3, monthFirst: false))
print(dayLabel(day: 5, month: 3, monthFirst: true))

let storage: [String: Storage] = [
    "locale": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("DateLabel", parent: nil, reads: ["locale"]),
    ViewNode("FooterNote", parent: nil, reads: []),
], storage: storage)

let recomputes = screen.change("locale-changed", key: "locale")
print(recomputes.count)
5.3
3/5
locale-changed: locale changed | recomputed DateLabel environment | skipped FooterNote
1

The first two lines are the same day written in two orders; this is not a real locale format, only an example of a pure function. The output of a real calendar format was not measured. The third line shows that the model counts only the view that reads the locale: DateLabel is recomputed through the environment trigger, FooterNote is skipped. The formatting function itself is not a view and has no place in the tree; the 1 on the last line is the number of recomputed views.

Summary

  • Localization covers text, format and layout direction; the framework offers the locale and the layout direction for these as environment values.
  • When the locale or the layout direction changes, only the view that reads that value is recomputed through the environment trigger.
  • The model’s date property recomputes the view that reads it through the observed trigger; the same view can be computed through both the environment and the observed channel.
  • Formatting is a pure function; it has no place in the tree, and only the view that reads the locale is counted.
  • This lesson’s trigger: environment.

Next Step

The last lesson of this topic looks at how these triggers are seen in a fast feedback loop. A preview runs a view with a sample model; which sample model is supplied determines which of the values measured above can change in the preview. In the next lesson we will cover the sample model injection of the preview and iteration.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close