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:
-
.automaticpicks the platform default: grouped on iOS, iPadOS, watchOS and visionOS, and columns on macOS. -
.groupedshows 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. -
.columnsis 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 |
|
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 |
|
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 |
|
A row with the current value and a pop-up menu (the |
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|---|---|---|
|
All |
The platform default for the context: plain text in a list, bordered in a toolbar or on a Mac |
|
All |
Just the label, with no chrome |
|
All |
A tinted capsule or rounded rectangle; the prominent variant is filled with the accent color |
|
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 |
|---|---|
|
A pop-up menu showing the current value; the default in forms on iOS and macOS |
|
A segmented control; best for two to five short options |
|
The options as rows in the form or list itself, with a checkmark |
|
A row that pushes a list of options onto the navigation stack (iOS, watchOS) |
|
A row of symbols or colors, often inside a |
|
A spinning wheel (iOS, watchOS) |
|
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
}
}
}
See Apple Developer Documentation — FocusState, Apple Developer Documentation -- focused(_:equals:) and Apple Developer Documentation -- defaultFocus(_:_:priority:).
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). AStringvariable is shown as is. UseText(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 aLocalizedStringKey.Text(note.body)shows the asterisks, because aStringisn’t parsed. -
Formatting is built in.
Text(note.createdAt, format: .dateTime.day().month())andText(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()
}
}
}
See Apple Developer Documentation — Text, Apple Developer Documentation — AttributedString and Apple Developer Documentation — Text input and output.
.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)
}
}
}
See Apple Developer Documentation — TextField, Apple Developer Documentation — bordered and Apple Developer Documentation -- textInputBorderShape(_:).
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)
}
}
}
}
}
See Apple Developer Documentation — TextEditor, Apple Developer Documentation — init(text:selection:) and Apple Developer Documentation — Building rich SwiftUI text experiences.
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)
See Apple Developer Documentation — Image and Apple Developer Documentation — Fitting images into available space.
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()
}
}
See Apple Developer Documentation -- asyncImageURLSession(_:) and Apple Developer Documentation — init(request:scale:content:placeholder:).
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 |
|---|---|
|
Every layer in the foreground style; the default |
|
One color, with secondary and tertiary layers at lower opacity to show depth |
|
An explicit color per layer, from |
|
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)")
See Apple Developer Documentation — init(systemName:variableValue:) and Apple Developer Documentation -- symbolVariableValueMode(_:).
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)
See Apple Developer Documentation -- symbolEffect(_:options:value:), Apple Developer Documentation -- symbolEffect(_:options:isActive:) and Apple Developer Documentation -- symbolEffect(_:options:).
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))
See Apple Developer Documentation — Font and Apple Developer Documentation — Applying custom fonts to text.
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)
See Apple Developer Documentation — ScaledMetric, Apple Developer Documentation — DynamicTypeSize and Apple Developer Documentation -- dynamicTypeSize(_:).
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)
}
See Apple Developer Documentation — Working with UI controls and Apple Developer Documentation — Controls and indicators.
The next page, Drawing, Animation and Gestures, goes below the built-in controls to shapes, custom drawing and touch handling.
References
-
Apple Developer Documentation -- defaultFocus(_:_:priority:)
-
Apple Developer Documentation — Building rich SwiftUI text experiences
-
Apple Developer Documentation — Fitting images into available space
-
Apple Developer Documentation — init(request:scale:content:placeholder:)
-
Apple Developer Documentation — init(systemName:variableValue:)
-
Apple Developer Documentation -- symbolVariableValueMode(_:)
-
Apple Developer Documentation -- symbolEffect(_:options:value:)
-
Apple Developer Documentation -- symbolEffect(_:options:isActive:)
-
Apple Developer Documentation — Applying custom fonts to text