Controls, Forms, Text, Images and SF Symbols

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Navigation and Presentation gave FieldNotes its screens; this page fills them. It covers the settings form, the note editor, the controls that change values, the views that display text and images, and the symbol system that gives the app its icons. Everything is written against the 27 releases (iOS 27, iPadOS 27, macOS 27, watchOS 27 and visionOS 27), built with Xcode 27 and Swift 6.4. FieldNotes keeps a deployment minimum of 26, so every API that exists only on the 27 releases is labelled "new in 27" and guarded with if #available.

SwiftUI controls share one idea: a control is described by its purpose and its data (a label, a binding, a role), and the platform decides how it looks. A Toggle is a switch on iPhone, a checkbox in a Mac form and a switch again on the watch, all from the same line of code. Styles let you pick a different look where the default doesn’t fit, but the starting point is always the platform’s own. Bindings, and where the bound values live, are explained on State and Data Flow with Observation.

SettingsView as a Form

A Form is a container for controls that edit settings or data. It groups its content into Sections and styles every control inside it in the platform’s form look. FieldNotes's SettingsView lives in the FieldNotesUI package, so the iOS app (in a sheet), the macOS app (in its Settings scene) and the watch app all compile the same source. The preferences are stored with @AppStorage, which is covered with the rest of UserDefaults on Preferences, Files and Documents:

// Packages/FieldNotesUI/Sources/FieldNotesUI/SettingsView.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

// NoteSortOrder comes from FieldNotesModel (defined on the Localization page) and is stored under the
// same "noteSortOrder" key as the note list's sort menu, so changing it here re-sorts the list.

public struct SettingsView: View {
    @Environment(NoteStore.self) private var store
    @AppStorage("syncWithICloud") private var syncWithICloud = true
    @AppStorage("pinnedFirst") private var pinnedFirst = true
    @AppStorage("noteSortOrder") private var sortOrder = NoteSortOrder.newestFirst
    @AppStorage("defaultNotebookID") private var defaultNotebookID = ""
    @AppStorage("locationRadius") private var locationRadius = 50.0
    @AppStorage("summarySentences") private var summarySentences = 3

    public init() {}

    public var body: some View {
        Form {
            Section("Sync") {
                Toggle("Sync with iCloud", isOn: $syncWithICloud)
            }
            Section("Notes") {
                Picker("Default Notebook", selection: $defaultNotebookID) {
                    Text("None").tag("")
                    ForEach(store.notebooks) { notebook in
                        Label(notebook.name, systemImage: notebook.symbolName)
                            .tag(notebook.id.uuidString)
                    }
                }
                Picker("Sort By", selection: $sortOrder) {
                    ForEach(NoteSortOrder.allCases) { Text($0.title).tag($0) }
                }
                Toggle("Show Pinned Notes First", isOn: $pinnedFirst)
            }
            Section("Capture") {
                Slider(value: $locationRadius, in: 10...500, step: 10) {
                    Text("Location Radius")
                } minimumValueLabel: {
                    Text("10 m")
                } maximumValueLabel: {
                    Text("500 m")
                }
                Stepper("Summary: \(summarySentences) sentences",
                        value: $summarySentences, in: 1...6)
            }
            Section("About") {
                LabeledContent("Notes", value: store.notes.count, format: .number)
                LabeledContent("Version", value: Bundle.main.appVersion)
            }
        }
        .formStyle(.grouped)
        .navigationTitle("Settings")
    }
}

Bundle.main.appVersion is a two-line extension reading CFBundleShortVersionString; version numbers themselves are set as described on Xcode Projects, Targets, Schemes and Previews.

A form has three built-in styles:

  • .automatic picks the platform default: grouped on iOS, iPadOS, watchOS and visionOS, and columns on macOS.

  • .grouped shows rounded section groups with the label on the leading side and the control on the trailing side. On macOS it gives the look of the System Settings app, which is why FieldNotes sets it explicitly: settings look like settings on every platform.

  • .columns is the classic Mac layout: labels right-aligned in a leading column, controls left-aligned in a trailing column, and sections separated by space rather than boxes.

Figure: The Same Form on iOS, macOS and watchOS

The table shows how each control in SettingsView renders with no platform-specific code. It is the reason to write settings as a Form instead of laying controls out by hand.

Element iOS and iPadOS (grouped) macOS (grouped; .columns in parentheses) watchOS

The form itself

An inset grouped list that scrolls; section headers above rounded groups

A scrolling pane of rounded groups, like System Settings (a two-column layout with right-aligned labels)

A single scrolling list of full-width rows, turned with the Digital Crown

Section("Sync")

Uppercase header above a rounded group

Bold header above a rounded box (a leading label beside the first row)

Small header text above its rows

Toggle

A switch at the trailing edge of the row

A trailing switch (a checkbox with the label beside it)

A switch at the trailing edge of the row

Picker

A row with the current value and a pop-up menu (the .menu style)

A pop-up button (the same pop-up button in the trailing column)

A row showing the current value that opens a list of choices

Slider

A horizontal track with the minimum and maximum labels at the ends

A horizontal track with the value labels (the same, in the trailing column)

Minus and plus buttons around a segmented bar, also driven by the Digital Crown

Stepper

The label, then minus and plus buttons at the trailing edge

The label, then small up and down arrows

Minus and plus buttons on either side of the label

LabeledContent

Label on the leading side, secondary value text on the trailing side

Label and value text in the row (label in the leading column, value in the trailing one)

Label above or beside the value, depending on width

Button in a form

A tinted, full-row tappable text

A push button inside the group (a push button in the trailing column)

A full-width rounded row

On the Mac, SettingsView is shown in the Settings scene that FieldNotesApp declares, which gives it the FieldNotes > Settings…​ menu item and the Command-comma shortcut for free (see App Structure and Life Cycle). On iPhone and iPad it is shown in a sheet from a toolbar button, as described on Navigation and Presentation.

Buttons and Button Styles

A Button has a label and an action. The label can be a string, a string with a system image, or any view. A role tells the system what the button means: .destructive colors it red and .cancel places it where cancel actions go in alerts and toolbars. The style decides how it looks:

Style Available Look

.automatic

All

The platform default for the context: plain text in a list, bordered in a toolbar or on a Mac

.plain, .borderless

All

Just the label, with no chrome

.bordered, .borderedProminent

All

A tinted capsule or rounded rectangle; the prominent variant is filled with the accent color

.glass, .glassProminent

iOS, iPadOS, macOS, watchOS and tvOS 26 (not visionOS)

Liquid Glass: a translucent, refracting capsule, clear or tinted for the prominent variant

// SettingsView: a destructive button with a confirmation, and an ordinary bordered one.
Section {
    Button("Export All Notes", systemImage: "square.and.arrow.up") { isExporting = true }
        .buttonStyle(.bordered)
    Button("Erase All Notes", role: .destructive) { isConfirmingErase = true }
}

// NoteList empty state: the primary call to action.
ContentUnavailableView {
    Label("No Notes Yet", systemImage: "note.text")
} actions: {
    Button("New Note") { isComposing = true }
        .buttonStyle(.borderedProminent)
        .controlSize(.large)
}

The Liquid Glass styles belong on controls that float above content, not on buttons inside a list or a form. FieldNotes's floating CaptureButton uses .glassProminent on the platforms that have it and falls back to .borderedProminent on visionOS, where windows already sit on glass:

// Packages/FieldNotesUI/Sources/FieldNotesUI/CaptureButton.swift
public struct CaptureButton: View {
    let action: () -> Void

    // A public type in a package needs an explicit public init; the memberwise one is internal.
    public init(action: @escaping () -> Void) {
        self.action = action
    }

    public var body: some View {
        Button("New Note", systemImage: "square.and.pencil", action: action)
            .labelStyle(.iconOnly)
            .font(.title2)
            .controlSize(.large)
            .buttonBorderShape(.circle)
            #if os(visionOS)
            .buttonStyle(.borderedProminent)
            #else
            .buttonStyle(.glassProminent)
            #endif
    }
}

controlSize(_:) (.mini to .extraLarge) and buttonBorderShape(_:) (.capsule, .circle, .roundedRectangle) work with every bordered and glass style. The full-custom glassEffect(_:in:) modifier, and when to use glass at all, are covered on Liquid Glass, Materials and Icons.

Value Controls: Toggle, Picker, Slider, Stepper and DatePicker

Each value control takes a label and a Binding to the value it edits. They appear in SettingsView above; this section covers the options that SettingsView doesn’t show.

Toggle

A Toggle edits a Bool. Its styles are .switch, .checkbox (macOS only), and .button, which draws a button that stays highlighted while on. FieldNotes uses the button style for the "pinned only" filter in the note list’s toolbar:

Toggle("Pinned Only", systemImage: "pin", isOn: $showsPinnedOnly)
    .toggleStyle(.button)

Picker

A Picker chooses one value from a set; each option is tagged with the value it stands for, and the tag’s type must match the selection’s type exactly (an Optional selection needs optional tags). The style changes the presentation completely:

Style Presentation

.menu

A pop-up menu showing the current value; the default in forms on iOS and macOS

.segmented

A segmented control; best for two to five short options

.inline

The options as rows in the form or list itself, with a checkmark

.navigationLink

A row that pushes a list of options onto the navigation stack (iOS, watchOS)

.palette

A row of symbols or colors, often inside a Menu

.wheel

A spinning wheel (iOS, watchOS)

.radioGroup

Radio buttons (macOS only)

// NoteList toolbar: a segmented scope for the list.
Picker("Show", selection: $scope) {
    Text("All").tag(NoteScope.all)
    Text("Pinned").tag(NoteScope.pinned)
    Text("Tagged").tag(NoteScope.tagged)
}
.pickerStyle(.segmented)

Slider and Stepper

A Slider edits a continuous value in a closed range, optionally in steps; a Stepper increments or decrements a value one step at a time, which is better when the exact number matters. Both can report editing changes. SettingsView uses onEditingChanged to preview the location radius on a map only while the user is dragging:

Slider(value: $locationRadius, in: 10...500, step: 10) {
    Text("Location Radius")
} onEditingChanged: { isEditing in
    showsRadiusPreview = isEditing
}

Stepper(value: $summarySentences, in: 1...6) {
    Text("Summary length: \(summarySentences) sentences")
}

DatePicker

A DatePicker edits a Date. displayedComponents limits it to .date, .hourAndMinute or both, and a range limits the dates it offers. In NoteEditor, the "Observed" date defaults to the note’s creation date and can’t be in the future:

DatePicker("Observed", selection: $store.notes[index].createdAt,
           in: ...Date.now,
           displayedComponents: [.date, .hourAndMinute])
    .datePickerStyle(.compact)

.compact shows a button with the formatted date that opens a calendar; .graphical embeds the calendar; .wheel (iOS, watchOS) and .field/.stepperField (macOS) are the other styles. On watchOS a date picker opens a dedicated screen of wheels.

Menu and LabeledContent

A Menu is a button that reveals a list of actions. Its content can hold buttons, pickers, toggles, dividers, sections and nested menus, so it is the natural home for sort and filter options. Given a primaryAction, it becomes a split control: a tap runs the action and a long press (or the arrow on a Mac) opens the menu. Menu exists on iOS, iPadOS, macOS, tvOS and visionOS, but not on watchOS, so FieldNotesWatch uses a toolbar button that opens a list instead:

// NoteList toolbar
Menu {
    Picker("Sort By", selection: $sortOrder) {
        ForEach(NoteSortOrder.allCases) { Text($0.title).tag($0) }
    }
    Toggle("Show Pinned First", isOn: $pinnedFirst)
    Divider()
    Menu("Filter by Tag") {
        ForEach(allTags, id: \.self) { tag in
            Button(tag) { tagFilter = tag }
        }
    }
} label: {
    Label("Sort and Filter", systemImage: "line.3.horizontal.decrease")
}

// A split button: tap for a new note, hold for templates.
Menu("New", systemImage: "square.and.pencil") {
    Button("Bird Sighting") { compose(template: .birdSighting) }
    Button("Trail Report") { compose(template: .trailReport) }
} primaryAction: {
    compose(template: .blank)
}

LabeledContent pairs a label with a value, or with any view, and gives it the same alignment as the form’s controls. With a string or a formatted value it produces read-only, selectable text, which is how NoteMetadataInspector (the inspector from the navigation page) shows a note’s details:

// FieldNotes/Views/NoteMetadataInspector.swift
struct NoteMetadataInspector: View {
    let note: Note

    var body: some View {
        Form {
            LabeledContent("Created", value: note.createdAt, format: .dateTime)
            LabeledContent("Words", value: note.body.split(whereSeparator: \.isWhitespace).count,
                           format: .number)
            LabeledContent("Tags") {
                HStack { ForEach(note.tags, id: \.self) { Text($0) } }
            }
        }
    }
}

Focus with @FocusState

Keyboard focus decides which text field receives typing, and on iPhone whether the keyboard is on screen at all. @FocusState exposes it as state: a Bool for a single field, or an optional Hashable enum when a screen has several fields. Binding a field to it with focused(_:equals:) makes the relationship two-way: typing into the field sets the state, and setting the state moves focus (or dismisses the keyboard, when set to nil).

NoteEditor uses it so that Return in the title moves to the body, a new note starts with the title focused, and a keyboard toolbar button dismisses the keyboard:

// FieldNotes/Views/NoteEditor.swift
struct NoteEditor: View {
    enum Field: Hashable { case title, body, tags }

    @Environment(NoteStore.self) private var store
    @FocusState private var focusedField: Field?
    @State private var newTag = ""
    let noteID: Note.ID

    var body: some View {
        @Bindable var store = store
        if let index = store.notes.firstIndex(where: { $0.id == noteID }) {
            Form {
                TextField("Title", text: $store.notes[index].title)
                    .font(.title2.bold())
                    .focused($focusedField, equals: .title)
                    .submitLabel(.next)
                    .onSubmit { focusedField = .body }

                TextEditor(text: $store.notes[index].body)
                    .focused($focusedField, equals: .body)
                    .frame(minHeight: 200)

                TextField("Add Tag", text: $newTag)
                    .focused($focusedField, equals: .tags)
                    .submitLabel(.done)
                    .onSubmit {
                        store.notes[index].tags.append(newTag)
                        newTag = ""
                    }

                DatePicker("Observed", selection: $store.notes[index].createdAt,
                           in: ...Date.now)
            }
            .defaultFocus($focusedField, .title)
            #if os(iOS)
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Spacer()
                    Button("Done") { focusedField = nil }
                }
            }
            #endif
        }
    }
}

Focus is not only for text. On macOS, iPadOS with a keyboard, tvOS and visionOS, buttons and custom views can be focusable too (focusable()), and focus drives keyboard navigation, which matters for accessibility; see Accessibility.

Text, AttributedString and Markdown

Text displays read-only text, and adapts to the environment’s font, color, line limit and layout direction. Three details trip up most newcomers:

  • A string literal is a localization key. Text("Notebooks") looks up "Notebooks" in the String Catalog (see Localization with String Catalogs). A String variable is shown as is. Use Text(verbatim:) for a literal that must never be translated.

  • Literals support inline Markdown; variables don’t. Text("Tap **New Note** to start") renders bold, because the literal becomes a LocalizedStringKey. Text(note.body) shows the asterisks, because a String isn’t parsed.

  • Formatting is built in. Text(note.createdAt, format: .dateTime.day().month()) and Text(count, format: .number) format locale-correctly and update when the locale changes.

To render Markdown that comes from data (the body of a note), parse it into an AttributedString first. An AttributedString is Foundation’s styled string: runs of characters, each with attributes such as font, foreground color, link or strikethrough. Text renders one directly:

// FieldNotes/Views/NoteBodyView.swift
struct NoteBodyView: View {
    let note: Note

    private var renderedBody: AttributedString {
        let options = AttributedString.MarkdownParsingOptions(
            interpretedSyntax: .inlineOnlyPreservingWhitespace)
        var text = (try? AttributedString(markdown: note.body, options: options))
            ?? AttributedString(note.body)
        // Highlight the note's tags wherever they appear in the text.
        for tag in note.tags {
            if let range = text.range(of: "#\(tag)") {
                text[range].foregroundColor = .accentColor
                text[range].font = .body.bold()
            }
        }
        return text
    }

    var body: some View {
        ScrollView {
            Text(renderedBody)
                .textSelection(.enabled)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
        }
    }
}

.inlineOnlyPreservingWhitespace interprets emphasis, code and links but keeps line breaks, which suits free-form notes; .full also parses block structure such as lists and headings. Links in the text are tappable and open with the openURL environment action, which FieldNotes overrides so that fieldnotes:// links are handled by the deep-link code on the navigation page.

TextField and TextEditor

A TextField edits a single line (or a growing multi-line field with axis: .vertical); a TextEditor edits long-form text in a scrolling view. Both take a binding to the text.

Text Field Styles and the New .bordered Style

The built-in text field styles are .automatic, .plain, .roundedBorder, .squareBorder (macOS), and, new in 27, .bordered, whose border shape is set with the new textInputBorderShape(_:) modifier (.automatic, .capsule or .roundedRectangle). FieldNotes's quick-capture bar pairs a capsule text field with a capsule button, falling back to .roundedBorder on 26:

// FieldNotes/Views/QuickCaptureBar.swift
struct QuickCaptureBar: View {
    @Environment(NoteStore.self) private var store
    @State private var title = ""

    var body: some View {
        HStack {
            TextField("Quick note", text: $title, axis: .vertical)
                .lineLimit(1...3)
                .modifier(CaptureFieldStyle())
                .onSubmit(save)
            Button("Add", action: save)
                .buttonStyle(.borderedProminent)
                .buttonBorderShape(.capsule)
                .disabled(title.isEmpty)
        }
        .padding()
    }

    private func save() {
        store.add(Note(title: title, body: ""))
        title = ""
    }
}

struct CaptureFieldStyle: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 27, macOS 27, watchOS 27, visionOS 27, *) {
            content
                .textFieldStyle(.bordered)            // new in 27
                .textInputBorderShape(.capsule)       // new in 27
        } else {
            content.textFieldStyle(.roundedBorder)
        }
    }
}

Text fields also take a format: instead of a String binding, for numbers, dates and custom ParseableFormatStyles, and modifiers such as keyboardType(_:), textContentType(_:), textInputAutocapitalization(_:) and autocorrectionDisabled(_:) tune the keyboard on iOS. SecureField is the password variant.

TextEditor for Plain and Rich Text

With a String binding, TextEditor edits plain text; that is what NoteEditor uses above. Since the 26 releases it also accepts a Binding<AttributedString> and becomes a rich-text editor: the system shows bold, italic, underline and other formatting controls in the context menu and, on iOS, above the keyboard. An optional AttributedTextSelection binding lets custom controls read and change the formatting of the selection:

// FieldNotes/Views/RichNoteEditor.swift (iOS, iPadOS, macOS and visionOS; not watchOS)
struct RichNoteEditor: View {
    @Binding var text: AttributedString
    @State private var selection = AttributedTextSelection()
    @Environment(\.fontResolutionContext) private var fontResolutionContext

    var body: some View {
        TextEditor(text: $text, selection: $selection)
            .toolbar {
                Button("Highlight", systemImage: "highlighter") {
                    text.transformAttributes(in: &selection) {
                        $0.backgroundColor = .yellow.opacity(0.4)
                    }
                }
            }
    }
}

TextEditor has no watchOS version: FieldNotesWatch edits with a TextField, which opens the system’s dictation, Scribble and keyboard input screen. Persisting an AttributedString body, rather than the String the model uses today, is shown with SwiftData on SwiftData.

Image, Resizing and AsyncImage

Image displays a bitmap or vector image from the asset catalog (Image("TrailheadPhoto")), from a system symbol (Image(systemName:)), or from a platform image. By default an image is shown at its natural size and ignores the space offered to it; resizable() lets it stretch, and an aspect-ratio modifier decides how it fills the frame:

// NoteRow: a square photo thumbnail, cropped to fill.
Image(decorative: "TrailheadPhoto")         // decorative: VoiceOver skips it
    .resizable()
    .scaledToFill()                            // .aspectRatio(contentMode: .fill)
    .frame(width: 56, height: 56)
    .clipShape(.rect(cornerRadius: 8))

// NoteDetail: the full photo, never cropped, never upscaled beyond the column.
Image("TrailheadPhoto")
    .resizable()
    .scaledToFit()
    .frame(maxWidth: 600)

scaledToFit() shows the whole image inside the frame (letterboxed if the ratios differ); scaledToFill() covers the frame and needs a clip shape to cut off the overflow. resizable(resizingMode: .tile) repeats the image instead of stretching it, and interpolation(_:) and antialiased(_:) control scaling quality for pixel art. Decorative images should be hidden from VoiceOver, which Image(decorative:) does for the thumbnail above; meaningful ones get a label (see Accessibility). Asset catalogs and app icons are covered on Liquid Glass, Materials and Icons.

AsyncImage and Its Cache (New in 27)

AsyncImage downloads an image from a URL and shows a placeholder until it arrives. The phase-based initializer lets the view distinguish loading, success and failure. FieldNotes uses it for map snapshots and shared photos stored on its sync server:

// FieldNotes/Views/NotePhoto.swift -- photoURL points at the sync server.
AsyncImage(url: photoURL) { phase in
    switch phase {
    case .success(let image):
        image.resizable().scaledToFill()
    case .failure:
        Image(systemName: "photo.badge.exclamationmark")
            .foregroundStyle(.secondary)
    default:
        ProgressView()
    }
}
.frame(height: 180)
.clipShape(.rect(cornerRadius: 12))

Until the 26 releases, AsyncImage used the shared URLSession and kept nothing, so a list that scrolled a row off screen and back downloaded its image again. In iOS 27, macOS 27, watchOS 27, tvOS 27 and visionOS 27, AsyncImage caches downloaded image data, following the HTTP caching headers the server sends. Two new APIs let you control that cache: init(request:) variants take a URLRequest with its own cache policy, and asyncImageURLSession(_:) supplies a session, for example one with a larger URLCache or authentication headers, to every AsyncImage below it:

// FieldNotes/Services/ImageSession.swift
extension URLSession {
    static let fieldNotesImages: URLSession = {
        let configuration = URLSessionConfiguration.default
        configuration.urlCache = URLCache(memoryCapacity: 32 << 20, diskCapacity: 256 << 20)
        return URLSession(configuration: configuration)
    }()
}

// NoteList: every AsyncImage in the list uses the app's image session on 27.
struct ImageSessionModifier: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 27, macOS 27, watchOS 27, visionOS 27, *) {
            content.asyncImageURLSession(.fieldNotesImages)      // new in 27
        } else {
            content
        }
    }
}

// Map snapshots change rarely: prefer the cache even when it is stale.
if #available(iOS 27, macOS 27, watchOS 27, visionOS 27, *) {
    AsyncImage(request: URLRequest(url: snapshotURL,
                                   cachePolicy: .returnCacheDataElseLoad)) { image in
        image.resizable().scaledToFit()
    } placeholder: {
        ProgressView()
    }
}

URLSession, caching and authentication in general are the subject of Networking. For large images, or when you need full control of decoding and memory, load the data yourself in a task and create the image from it instead of using AsyncImage.

SF Symbols

SF Symbols is Apple’s library of several thousand vector icons, designed to align with the San Francisco system font. A symbol scales with the text around it, takes the font’s weight, and aligns with its baseline, so Label("Pinned", systemImage: "pin.fill") looks right at every Dynamic Type size. Browse the library, check a symbol’s availability per OS release, and create custom symbols with the free SF Symbols app.

// Symbols follow the font: size, weight and baseline come from the text style.
Label("Field Trips", systemImage: "map")
    .font(.headline)

// Variants: .fill, .circle, .slash... applied to every symbol below.
HStack {
    Image(systemName: "pin")
    Image(systemName: "bell")
}
.symbolVariant(.fill)

// Scale relative to the font without changing it.
Image(systemName: "bird").imageScale(.large)

Rendering Modes

A symbol is made of layers, and the rendering mode decides how they are colored:

Mode Coloring

.monochrome

Every layer in the foreground style; the default

.hierarchical

One color, with secondary and tertiary layers at lower opacity to show depth

.palette

An explicit color per layer, from foregroundStyle(_:_:_:)

.multicolor

The symbol’s own fixed colors (a red trash can badge, a yellow sun), where it has them

// NotebookList: hierarchical notebook icons in the notebook's tint.
Image(systemName: notebook.symbolName)
    .symbolRenderingMode(.hierarchical)
    .foregroundStyle(.teal)

// Sync status: palette mode, with a white arrow on a green circle.
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
    .symbolRenderingMode(.palette)
    .foregroundStyle(.white, .green)

// Weather in a note header: the symbol's own colors.
Image(systemName: "cloud.sun.rain.fill")
    .symbolRenderingMode(.multicolor)

Variable Values

Some symbols have a variable value between 0 and 1 that fills their layers progressively: signal bars, a speaker’s waves, a thermometer. FieldNotes shows the GPS accuracy of a note’s location this way. Since the 26 releases symbolVariableValueMode(.draw) animates the value by drawing the path along its length, instead of the default .color mode that switches whole layers on and off:

// NoteDetail: 0 = no fix, 1 = best accuracy.
Image(systemName: "location.north.circle", variableValue: gpsAccuracy)
    .symbolVariableValueMode(.draw)
    .font(.title3)
    .accessibilityLabel("GPS accuracy \(gpsAccuracy, format: .percent)")

The SF Symbols app shows which symbols support variable values and the draw mode; a symbol without them ignores the value.

Symbol Effects

symbolEffect animates a symbol in ways that follow its design: a discrete effect such as .bounce or .wiggle plays once each time a value changes; an indefinite effect such as .pulse, .variableColor or .breathe runs while active; and a content transition such as .replace animates from one symbol to another:

// NoteRow: bounce the pin each time the note is pinned or unpinned.
Image(systemName: note.isPinned ? "pin.fill" : "pin")
    .contentTransition(.symbolEffect(.replace))
    .symbolEffect(.bounce, value: note.isPinned)

// Sync status: pulse while a sync is in progress.
Image(systemName: "icloud")
    .symbolEffect(.pulse, isActive: syncState == .syncing)

// Accessibility: respect Reduce Motion by removing effects.
.symbolEffectsRemoved(reduceMotion)

reduceMotion is @Environment(\.accessibilityReduceMotion). General animation, of which symbol effects are a specialized case, is covered on Drawing, Animation and Gestures.

Dynamic Type

Dynamic Type lets people choose their preferred text size system-wide, from xSmall to five accessibility sizes (AX1 to AX5). SwiftUI supports it automatically as long as you use text styles (.body, .headline, .caption…​) instead of fixed point sizes. Custom fonts scale too when created with Font.custom(_:size:relativeTo:):

// NoteRow
VStack(alignment: .leading) {
    Text(note.title)
        .font(.headline)
    Text(note.body)
        .font(.subheadline)
        .foregroundStyle(.secondary)
        .lineLimit(2)
    Text(note.createdAt, format: .relative(presentation: .named))
        .font(.caption)
}

// A custom field-journal font that still follows Dynamic Type.
Text(note.title)
    .font(.custom("Charter", size: 20, relativeTo: .title3))

Text is only half of it: spacing, icon sizes and layout must grow too. @ScaledMetric scales a number in proportion to a text style, the dynamicTypeSize environment value tells a view which size is active, and dynamicTypeSize(_:) limits the range a view supports when a layout genuinely can’t grow further:

struct NoteRow: View {
    @Environment(\.dynamicTypeSize) private var typeSize
    @ScaledMetric(relativeTo: .headline) private var thumbnailSize = 56.0
    let note: Note

    var body: some View {
        let layout = typeSize.isAccessibilitySize
            ? AnyLayout(VStackLayout(alignment: .leading))
            : AnyLayout(HStackLayout(alignment: .top))

        layout {
            NoteThumbnail(note: note)
                .frame(width: thumbnailSize, height: thumbnailSize)
            NoteRowText(note: note)
        }
    }
}

// The watch complication preview has a fixed canvas: cap the size there.
ComplicationPreview()
    .dynamicTypeSize(...DynamicTypeSize.xxLarge)

Switching from a horizontal to a vertical layout at accessibility sizes is the most common Dynamic Type fix; AnyLayout and ViewThatFits are covered on SwiftUI Layout. In Xcode, the Variants button in the preview canvas shows a view at every Dynamic Type size at once, and the Accessibility Inspector changes the size on a running simulator. Testing and auditing it are covered on Accessibility.

Where to Practice

Apple’s Working with UI controls tutorial builds a profile editor with most of the controls on this page, and is a good hands-on companion to it:

// The shape of that tutorial's editor, in FieldNotes terms: a Form of bound controls.
Form {
    TextField("Notebook Name", text: $draft.name)
    Picker("Icon", selection: $draft.symbolName) {
        ForEach(["map", "bird", "leaf", "mountain.2"], id: \.self) { name in
            Image(systemName: name).tag(name)
        }
    }
    .pickerStyle(.palette)
}

The next page, Drawing, Animation and Gestures, goes below the built-in controls to shapes, custom drawing and touch handling.

References