Accessibility

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.

Accessibility on Apple platforms is not a separate mode that an app switches on. Every view an app draws also produces an accessibility element, and assistive technologies such as VoiceOver, Voice Control, Switch Control and Full Keyboard Access read and drive the app through those elements. This page shows how FieldNotes makes those elements accurate and complete, using the APIs of the 27 releases (iOS, iPadOS, macOS, watchOS and visionOS 27), built with Xcode 27 and Swift 6.4, while the app keeps its deployment minimum of 26. Every API used on this page is already available on the 26 releases, so none of the examples needs an if #available check; the Xcode 27 tools (Device Hub in particular) are the part that is new.

SwiftUI gives a lot of accessibility for free: a Button, a Toggle, a List row or a NavigationLink already has a label, a role and an action. The work is in the places SwiftUI cannot guess: rows that combine several texts and icons, custom-drawn controls, icons without text, layouts that must reflow at very large text sizes, and animations or translucency that some people need turned down. The general principles (the POUR model, WCAG levels and why automated checks are not enough) are the same as on the web and are covered on Web Accessibility; this page covers only how they map onto Apple’s frameworks. For the design side, see Human Interface Guidelines in Practice.

How the Accessibility Tree Is Built

SwiftUI walks the view hierarchy and produces a parallel accessibility tree. Most views become one element; containers such as stacks become transparent and pass their children through; decorative views can be removed; and a group of views can be merged into a single element with one combined label. VoiceOver moves through the leaves of that tree in reading order (top to bottom, then leading to trailing), speaking each element’s label, then its value, then its traits (such as "Button" or "Selected"), and, after a pause, its hint.

The NoteRow in FieldNotes's note list is the running example. Visually it is a pin icon, a title, a two-line excerpt, a date and a row of TagChip views. Left alone, SwiftUI would expose five or more separate elements for every row, so a VoiceOver user would need five swipes per note. After the changes on this page, each row is one element with a spoken summary and a set of actions.

Figure: from view tree to accessibility tree

flowchart LR subgraph VT["View tree (NoteRow)"] R["HStack: NoteRow"] P["Image: pin.fill"] V["VStack"] T["Text: title"] E["Text: excerpt"] D["Text: date"] TG["HStack: TagChip x3"] R --> P R --> V V --> T V --> E V --> D V --> TG end subgraph AT["Accessibility tree"] A1["Element: NoteRow
label: title, excerpt, date
value: Pinned
traits: isButton
actions: Pin, Delete"] X1["pin image: hidden,
folded into value"] X2["tag chips: ignored,
exposed as custom content"] end R -- "accessibilityElement(children: .combine)" --> A1 P -. "accessibilityHidden(true)" .-> X1 TG -. "accessibilityCustomContent" .-> X2

VoiceOver: Label, Value, Hint and Traits

Four properties describe every element. Getting them right is most of the VoiceOver work in a typical app.

Property What it answers NoteRow example

Label

"What is this?" Short, no control type, starts with a capital letter.

"Survey of the north ridge"

Value

"What is its current state?" Changes over time.

"Pinned"

Hint

"What happens if I activate it?" Optional, spoken after a pause, can be turned off by the user.

"Opens the note"

Traits

"What kind of element is it?" Button, header, selected, image, toggle and so on.

.isButton

Labels

A Text is its own label, and a Button("Add Note", systemImage: "plus") uses its title even when only the icon is shown. Icon-only views are the common gap: an Image(systemName:) gets a label derived from the symbol name, which is rarely what the app means.

struct NoteToolbar: ToolbarContent {
    let onCapture: () -> Void

    var body: some ToolbarContent {
        ToolbarItem(placement: .primaryAction) {
            Button(action: onCapture) {
                Image(systemName: "camera.viewfinder")
            }
            .accessibilityLabel("Capture Photo Note")
        }
    }
}

Values

The value carries state that changes independently of the label. For NoteRow the pinned state is a value rather than part of the label, so VoiceOver announces it again when it changes and the label stays stable for Voice Control ("Tap Survey of the north ridge").

struct NoteRow: View {
    let note: Note

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 12) {
            Image(systemName: note.isPinned ? "pin.fill" : "note.text")
                .accessibilityHidden(true)           // decorative: state goes into the value
            VStack(alignment: .leading, spacing: 4) {
                Text(note.title).font(.headline)
                Text(note.body).font(.subheadline).lineLimit(2)
                Text(note.createdAt, format: .dateTime.day().month().year())
                    .font(.caption)
            }
        }
        .accessibilityValue(note.isPinned ? Text("Pinned") : Text(""))
    }
}

Hints

Hints describe the result of activating an element, in the third person and without naming the gesture ("Opens the note", not "Double-tap to open"). Many users turn hints off, so a hint never carries information that the label and value do not.

NavigationLink(value: note) {
    NoteRow(note: note)
}
.accessibilityHint("Opens the note")

Traits

Traits tell assistive technologies how an element behaves. SwiftUI sets them for standard controls; custom views add or remove them. NoteDetail marks its section titles as headers so the VoiceOver Headings rotor can jump between them, and the selected notebook in the sidebar reports itself as selected.

struct NoteDetailSection: View {
    let title: LocalizedStringKey

    var body: some View {
        Text(title)
            .font(.title3.weight(.semibold))
            .accessibilityAddTraits(.isHeader)
    }
}

struct NotebookCell: View {
    let notebook: Notebook
    let isSelected: Bool

    var body: some View {
        Label(notebook.name, systemImage: notebook.symbolName)
            .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
    }
}

Grouping Elements with accessibilityElement(children:)

accessibilityElement(children:) turns a container into a single accessibility element and decides what happens to its children. The three AccessibilityChildBehavior values are:

  • .combine merges the children’s labels (in order, separated by commas) and their actions into one new element. This is the right choice for NoteRow.

  • .contain keeps the children as separate elements but groups them, so VoiceOver treats the container as one navigation unit (useful for a toolbar of related buttons or a card with two independent buttons).

  • .ignore (the default when you call the modifier with no argument) creates a new, empty element and hides the children; you then provide the label and value yourself.

// NoteRowLayout switches between HStack and VStack; see "Dynamic Type" below.
struct NoteRow: View {                     // final version, used by NoteList
    let note: Note

    var body: some View {
        NoteRowLayout {
            Image(systemName: note.isPinned ? "pin.fill" : "note.text")
                .accessibilityHidden(true)
            VStack(alignment: .leading, spacing: 4) {
                Text(note.title).font(.headline)
                Text(note.body).font(.subheadline).lineLimit(2)
                Text(note.createdAt, format: .dateTime.day().month().year()).font(.caption)
                TagChipRow(tags: note.tags)
                    .accessibilityHidden(true)    // exposed as custom content instead
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityValue(note.isPinned ? Text("Pinned") : Text(""))
        .accessibilityCustomContent("Tags", note.tags.joined(separator: ", "))
    }
}

struct NotebookHeader: View {
    let notebook: Notebook
    let noteCount: Int

    var body: some View {
        HStack {
            Image(systemName: notebook.symbolName)
            Text(notebook.name)
            Spacer()
            Text("\(noteCount)")
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(notebook.name)
        .accessibilityValue("\(noteCount) notes")
        .accessibilityAddTraits(.isHeader)
    }
}

accessibilityCustomContent(_:_:importance:) keeps secondary details (here, the tags) out of the spoken label but still available: VoiceOver users hear them through the More Content rotor, so a row with twelve tags does not read all twelve every time it gains focus.

Replacing Accessibility with accessibilityRepresentation

Sometimes a view is drawn from shapes and gestures, so SwiftUI cannot tell what it is. Rather than rebuilding its semantics by hand with traits, values and actions, accessibilityRepresentation(representation:) lets the app describe it as a standard control. SwiftUI hides the representation view, makes it non-interactive and uses it only to generate accessibility elements.

FieldNotes has a custom-drawn pin switch in NoteEditor, built from a capsule and a DragGesture. For assistive technologies it is simply a Toggle:

struct PinSwitch: View {
    @Binding var isPinned: Bool

    var body: some View {
        Capsule()
            .fill(isPinned ? Color.orange : Color.secondary.opacity(0.3))
            .frame(width: 52, height: 32)
            .overlay(alignment: isPinned ? .trailing : .leading) {
                Circle().fill(.white).padding(3)
            }
            .onTapGesture { isPinned.toggle() }
            .accessibilityRepresentation {
                Toggle("Pinned", isOn: $isPinned)
            }
    }
}

In the Xcode 27 SDK the representation closure is a @ContentBuilder, the builder that replaces the older type-specific builders; the call site is unchanged. The representation also works for Voice Control ("Tap Pinned") and Switch Control, because they read the same element.

Custom and Adjustable Actions

Custom actions

Swipe actions, context menus and small inline buttons are hard to reach with VoiceOver when a row is a single combined element. Custom actions attach those commands to the element itself; VoiceOver lists them in the Actions rotor (swipe up or down to choose, double-tap to perform), and Voice Control and Switch Control offer them too.

struct NoteList: View {
    @Environment(NoteStore.self) private var store

    var body: some View {
        List(store.notes) { note in
            NavigationLink(value: note) {
                NoteRow(note: note)
                    .accessibilityElement(children: .combine)
            }
            .swipeActions {
                Button("Delete", systemImage: "trash", role: .destructive) { store.delete(note) }
            }
            .accessibilityAction(named: note.isPinned ? "Unpin" : "Pin") {
                store.togglePin(note)
            }
            .accessibilityAction(named: "Delete") {
                store.delete(note)
            }
        }
    }
}

SwiftUI already exposes a row’s .swipeActions as accessibility actions, so the explicit "Delete" above is only needed when the row is not in a List or when the swipe action is missing on a platform (for example, on macOS the app uses a context menu instead).

Adjustable actions

An adjustable element is one that VoiceOver users change by swiping up and down, like a slider or a page control. FieldNotes shows recent notebooks in a horizontal NotebookCarousel on iPhone; as an adjustable element, VoiceOver swipes up and down move to the next or previous notebook, and the value announces the new one.

struct NotebookCarousel: View {
    let notebooks: [Notebook]
    @Binding var selection: Notebook.ID?

    private var index: Int {
        notebooks.firstIndex { $0.id == selection } ?? 0
    }

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack { ForEach(notebooks) { NotebookCard(notebook: $0) } }
                .scrollTargetLayout()
        }
        .scrollPosition(id: $selection)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Notebooks")
        .accessibilityValue(notebooks.isEmpty ? "" : notebooks[index].name)
        .accessibilityAdjustableAction { direction in
            guard !notebooks.isEmpty else { return }
            switch direction {
            case .increment: selection = notebooks[min(index + 1, notebooks.count - 1)].id
            case .decrement: selection = notebooks[max(index - 1, 0)].id
            @unknown default: break
            }
        }
    }
}

accessibilityIdentifier Is for UI Tests Only

accessibilityIdentifier(_:) attaches a stable, non-localized string to an element. Assistive technologies never read it; it exists so that XCUITest can find elements without depending on the label, which changes with the language. Use it only for that purpose, and never as a substitute for a label.

Button("Add Note", systemImage: "plus") { store.add(Note(title: "")) }
    .accessibilityIdentifier("add-note-button")
// FieldNotesUITests
let app = XCUIApplication()
app.launch()
app.buttons["add-note-button"].tap()

Dynamic Type

Dynamic Type lets people choose a text size from xSmall up to AX5, the largest of five accessibility sizes. Text that uses a text style (.body, .headline, .caption and so on) scales automatically. Three things need extra work: fixed-size numbers that should grow with the text, layouts that no longer fit at accessibility sizes, and the rare view that cannot grow at all.

@ScaledMetric for spacing and icon sizes

@ScaledMetric scales any numeric value in proportion to the current text size, optionally relative to a specific text style. TagChipStyle, the modifier behind the TagChip view (both introduced on SwiftUI Views and Modifiers), uses it so its padding and its symbol grow with its caption text:

// Packages/FieldNotesUI/Sources/FieldNotesUI/TagChipStyle.swift (revised for Dynamic Type)
struct TagChipStyle: ViewModifier {
    var isSelected = false
    @ScaledMetric(relativeTo: .caption) private var horizontalPadding = 8.0
    @ScaledMetric(relativeTo: .caption) private var iconSize = 12.0

    func body(content: Content) -> some View {
        HStack(spacing: 4) {
            Image(systemName: "tag")
                .resizable().scaledToFit()
                .frame(width: iconSize, height: iconSize)
                .accessibilityHidden(true)
            content.font(.caption)
        }
        .padding(.horizontal, horizontalPadding)
        .padding(.vertical, horizontalPadding / 2)
        .foregroundStyle(isSelected ? Color.white : Color.primary)
        .background(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.quaternary), in: Capsule())
    }
}

Switching layout at accessibility sizes

At the accessibility sizes a horizontal row of icon, text and date no longer fits on an iPhone. NoteRow reads the dynamicTypeSize environment value and switches from an HStack to a VStack with AnyLayout, which keeps the view identity (and therefore animations and state) across the switch.

struct NoteRowLayout<Content: View>: View {
    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
    @ViewBuilder var content: Content

    var body: some View {
        let layout = dynamicTypeSize.isAccessibilitySize
            ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6))
            : AnyLayout(HStackLayout(alignment: .firstTextBaseline, spacing: 12))
        layout { content }
    }
}

ViewThatFits is the alternative when the right choice depends on the available width rather than the text size; see SwiftUI Layout. A view that genuinely cannot grow (a compact toolbar item, for example) can cap its size with dynamicTypeSize(…​DynamicTypeSize.accessibility2) and offer the Large Content Viewer instead, which shows an enlarged copy of the item while the user presses it:

struct SyncButton: View {
    let syncer: any NoteSyncing

    var body: some View {
        Button("Sync", systemImage: "arrow.triangle.2.circlepath") {
            Task { try? await syncer.sync() }
        }
        .dynamicTypeSize(...DynamicTypeSize.accessibility2)
        .accessibilityShowsLargeContentViewer()
    }
}

System Display Settings

People can ask the whole system to move less, blur less, contrast more and rely less on color. SwiftUI’s own controls, system materials and Liquid Glass adapt to these settings automatically; custom drawing reads them from the environment.

Setting SwiftUI environment value What FieldNotes does

Reduce Motion

accessibilityReduceMotion

Replaces the sliding pin animation with a cross-fade.

Reduce Transparency

accessibilityReduceTransparency

Draws the capture overlay on an opaque background instead of a translucent one.

Increase Contrast

colorSchemeContrast (.increased)

Uses higher-contrast colors from the asset catalog and adds borders to tag chips.

Differentiate Without Color

accessibilityDifferentiateWithoutColor

Adds a pin symbol next to the orange "pinned" tint so the state is not conveyed by color alone.

Reduce Motion

struct PinBadge: View {
    let isPinned: Bool
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        Image(systemName: "pin.fill")
            .opacity(isPinned ? 1 : 0)
            .offset(y: isPinned || reduceMotion ? 0 : -12)
            .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring, value: isPinned)
    }
}

Reduce Motion does not mean no animation: fades and small changes are fine, while large movement, zooming, parallax and spinning are what the setting is meant to avoid.

Reduce Transparency

CaptureControlsPanel is the translucent strip that holds CaptureButton above the note list. It is a different view from CaptureOverlay on SwiftUI Layout, which only positions the button away from the iPhone Duo hinge. Its material background gives way to an opaque one when Reduce Transparency is on:

struct CaptureControlsPanel: View {
    var startCapture: () -> Void
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency

    var body: some View {
        HStack(spacing: 12) {
            Text("New note from the camera")
            CaptureButton(action: startCapture)
        }
        .padding()
        .background {
            if reduceTransparency {
                RoundedRectangle(cornerRadius: 20).fill(Color(.systemBackground))
            } else {
                RoundedRectangle(cornerRadius: 20).fill(.ultraThinMaterial)
            }
        }
    }
}

CaptureButton uses Liquid Glass (see Liquid Glass, Materials and Icons); the system already renders glass more opaquely under Reduce Transparency and with a stronger border under Increase Contrast, so it needs no code of its own. Color(.systemBackground) is the iOS spelling; the macOS target uses Color(nsColor: .windowBackgroundColor) behind #if os(macOS).

Increase Contrast

struct TagChipBorder: ViewModifier {
    @Environment(\.colorSchemeContrast) private var contrast

    func body(content: Content) -> some View {
        content.overlay {
            if contrast == .increased {
                Capsule().strokeBorder(.primary, lineWidth: 1)
            }
        }
    }
}

Colors themselves are best handled in the asset catalog: each color set can have a High Contrast variant for light and dark appearance, which the system picks up with no code.

Differentiate Without Color

struct SyncStatusDot: View {
    let isSynced: Bool
    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor

    var body: some View {
        if differentiateWithoutColor {
            Image(systemName: isSynced ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
                .foregroundStyle(isSynced ? .green : .orange)
        } else {
            Circle().fill(isSynced ? .green : .orange).frame(width: 8, height: 8)
        }
    }
}

A good default is to never rely on color alone in the first place; the environment value is for the cases where a compact colored dot is the right design for most people.

An Accessible Custom Control

A fully custom control needs every piece from the previous sections at once. TagFilterBar, above NoteList, is a wrapping row of selectable tag chips that filters the list. To be accessible it must:

  1. expose each chip as a toggle with a clear label and a selected state;

  2. give Voice Control users short spoken names (accessibilityInputLabels);

  3. announce the result of a change, since the list updates somewhere else on screen;

  4. move VoiceOver focus sensibly when the filter is cleared.

struct TagFilterBar: View {
    let tags: [String]
    @Binding var selected: Set<String>
    let resultCount: Int
    @AccessibilityFocusState private var focusedTag: String?

    var body: some View {
        FlowLayout {
            ForEach(tags, id: \.self) { tag in
                let isOn = selected.contains(tag)
                Button {
                    if isOn { selected.remove(tag) } else { selected.insert(tag) }
                } label: {
                    TagChip(tag, isSelected: isOn)
                }
                .buttonStyle(.plain)
                .accessibilityLabel(tag)
                .accessibilityAddTraits(isOn ? [.isToggle, .isSelected] : .isToggle)
                .accessibilityInputLabels(["\(tag)", "Tag \(tag)"])
                .accessibilityFocused($focusedTag, equals: tag)
            }
            if !selected.isEmpty {
                Button("Clear Filters") {
                    selected.removeAll()
                    focusedTag = tags.first          // the Clear button disappears; move focus
                }
            }
        }
        .accessibilityElement(children: .contain)
        .accessibilityLabel("Tag filters")
        .onChange(of: resultCount) {
            // The list updates elsewhere on screen, so announce the result.
            AccessibilityNotification.Announcement(String(localized: "\(resultCount) notes shown")).post()
        }
    }
}

FlowLayout is the custom Layout from SwiftUI Layout. Because each chip is a real Button, it is already reachable by Full Keyboard Access and Switch Control; the .isToggle trait makes VoiceOver say "Switch button" and read the selected state. When a custom control can be expressed as a standard one, prefer a custom ButtonStyle or ToggleStyle over a fully custom view: styled standard controls inherit all of their accessibility automatically.

Assistive Access

Assistive Access is an iOS and iPadOS mode for people with cognitive disabilities. A trusted supporter turns it on in Settings > Accessibility > Assistive Access and chooses which apps are available. The system shows large, high-contrast controls, a prominent Back button and a simplified home screen. An app can take part in three ways:

  • Do nothing. The app runs in a reduced frame above the Back button, the size of a smaller iPhone or iPad.

  • Full screen. Set UISupportsFullScreenInAssistiveAccess to YES so the existing UI fills the space.

  • Streamlined UI. Set UISupportsAssistiveAccess to YES and add an AssistiveAccess scene with a purpose-built, simpler interface. The scene type and the key are available from iOS and iPadOS 26, which is FieldNotes's deployment minimum, so no availability check is needed.

<!-- FieldNotes/Info.plist -->
<key>UISupportsAssistiveAccess</key>
<true/>
@main
struct FieldNotesApp: App {
    @State private var store = NoteStore()

    var body: some Scene {
        WindowGroup {
            ContentView().environment(store)
        }
        #if os(iOS)
        AssistiveAccess {
            SimpleNoteList()          // big rows, one action per screen, no tag editing
                .environment(store)
        }
        #endif
    }
}

For smaller adjustments inside the normal UI, read the accessibilityAssistiveAccessEnabled environment value and hide the workflows that do not make sense in that mode (bulk export, for example):

struct NoteDetailToolbar: View {
    @Environment(\.accessibilityAssistiveAccessEnabled) private var assistiveAccess

    var body: some View {
        if !assistiveAccess {
            ExportMenu()
        }
    }
}

Testing Accessibility

No single tool finds every problem. FieldNotes combines four layers, from cheapest to most realistic.

Accessibility Inspector

Accessibility Inspector (Xcode > Open Developer Tool > Accessibility Inspector) points at any element in a running app, on a simulator, device or the Mac, and shows its label, value, traits, identifier and actions. Its Audit tab runs automated checks (missing labels, clipped text, small hit areas, low contrast), and its Settings tab toggles Dynamic Type, Reduce Motion, Increase Contrast and the other display settings without leaving the app.

Automated audits in UI tests

performAccessibilityAudit(for:_:) runs the same audits from a UI test, so regressions fail the build. The optional closure receives each issue and returns true to ignore it; FieldNotes ignores only the known contrast issue on its decorative gradient.

import XCTest

final class AccessibilityAuditTests: XCTestCase {
    @MainActor
    func testNoteListPassesAudit() throws {
        let app = XCUIApplication()
        app.launch()

        try app.performAccessibilityAudit(for: [.dynamicType, .contrast, .sufficientElementDescription,
                                                .hitRegion, .textClipped]) { issue in
            // Ignore the decorative header gradient; fail on everything else.
            issue.auditType == .contrast && issue.element?.identifier == "noteList.headerGradient"
        }
    }
}

UI tests run under XCTest (Swift Testing does not drive UI yet); the rest of FieldNotes's tests use Swift Testing, as described on Unit Testing with Swift Testing.

Previews and Device Hub overrides

The Xcode canvas can show Dynamic Type variants of a preview side by side (the Variants button, then Dynamic Type Variants), and a preview can pin one size in code:

#Preview("NoteRow at AX3", traits: .sizeThatFitsLayout) {
    NoteRow(note: .sample)
        .dynamicTypeSize(.accessibility3)
}

In Xcode 27, simulators run inside Device Hub (see Getting Started with Apple Platforms). Select a simulated device and use the Settings inspector to change Text Size, Appearance and the other environment overrides while the app is running, without opening the simulated Settings app.

Manual VoiceOver and Voice Control passes

Automated audits cannot tell whether a label makes sense or whether the reading order matches the task. Before each release, FieldNotes is tested on a real device with each main task (create a note, pin it, filter by tag, delete it) performed:

  • with VoiceOver on, using only swipes, the rotor and double-taps (triple-click the side button for the Accessibility Shortcut);

  • with Voice Control on, using "Show names" and "Show numbers" to check every control has a speakable name;

  • with Larger Text at AX5, Bold Text, Increase Contrast, Reduce Motion and Reduce Transparency, one at a time;

  • with a hardware keyboard and Full Keyboard Access on iPad and with the keyboard alone on the Mac.

Accessibility Nutrition Labels

Accessibility Nutrition Labels are a section on the App Store product page, shown on devices running the 26 releases and later, that tells people which accessibility features an app supports before they download it. Developers declare them per device type in App Store Connect (App Accessibility), and they also feed App Store search: a query such as "VoiceOver note taking apps" ranks apps that declare VoiceOver support higher. The labels began as voluntary; Apple has said they will become required for new apps and updates over time, so FieldNotes declares them from its first release. Accessibility Nutrition Labels supersede treating the Accessibility Inspector audits and manual passes from Testing Accessibility above as internal QA documentation only: on the 26 releases the same testing also feeds a public, per-feature declaration on the App Store product page.

There are nine labels. Apple publishes evaluation criteria for each one, and a label should be declared only when the app’s common tasks can be completed with that feature on the given device type. Larger Text is not offered for the Mac; Voice Control is not offered for Apple Watch or Apple TV.

The submission workflow in App Store Connect is covered on App Store Connect, TestFlight and App Review.

Figure: nutrition labels, supporting APIs and how to test them

Label Supporting APIs in FieldNotes How to test it

VoiceOver

accessibilityLabel, accessibilityValue, accessibilityHint, traits, accessibilityElement(children:), accessibilityAction(named:), accessibilityRepresentation

Complete every common task with VoiceOver only; performAccessibilityAudit(for: .sufficientElementDescription).

Voice Control

Visible labels matching accessibilityLabel, accessibilityInputLabels, standard controls

"Show names", then operate every control by voice.

Larger Text

Text styles, @ScaledMetric, dynamicTypeSize.isAccessibilitySize with AnyLayout, accessibilityShowsLargeContentViewer()

AX5 in Device Hub or Accessibility Inspector; Dynamic Type preview variants; audit types .dynamicType and .textClipped.

Dark Interface

Semantic colors and asset-catalog color sets with dark variants, colorScheme

Appearance override in Device Hub; check every screen, including sheets and alerts.

Differentiate Without Color Alone

accessibilityDifferentiateWithoutColor, symbols and text next to color

Turn on the setting; review each status indicator; grayscale color filter.

Sufficient Contrast

High Contrast color variants, colorSchemeContrast

Audit type .contrast; Accessibility Inspector contrast checks with Increase Contrast on and off.

Reduced Motion

accessibilityReduceMotion, cross-fades instead of movement

Turn on Reduce Motion and run every animated flow.

Captions

AVKit’s VideoPlayer with caption tracks for video notes

Turn on Subtitles & Captioning; play a video note.

Audio Descriptions

Audio-description tracks in media assets (AVFoundation media selection)

Turn on Audio Descriptions; play a video note.

FieldNotes supports the first seven. Its video notes are recorded by users and have no captions or descriptions, so it does not claim Captions or Audio Descriptions; declaring them would be misleading.

Accessibility and the Web

The concepts on this page map closely onto the web: an accessibility label is an accessible name (aria-label), traits are ARIA roles and states, accessibilityElement(children: .combine) resembles a single focusable container with a composed name, and Dynamic Type is the platform’s equivalent of respecting the user’s font size. WCAG 2 success criteria remain a useful checklist for native apps as well, and several regulations (such as the European Accessibility Act) apply to apps as well as sites. See Web Accessibility for WCAG, conformance levels and legal requirements, and the Human Interface Guidelines page for Apple’s design guidance.

References