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
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. |
|
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:
-
.combinemerges the children’s labels (in order, separated by commas) and their actions into one new element. This is the right choice forNoteRow. -
.containkeeps 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)
}
}
See Apple Developer Documentation — accessibilityElement(children:) and Apple Developer Documentation — AccessibilityChildBehavior.
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()
See Apple Developer Documentation -- accessibilityIdentifier(_:). UI tests themselves are covered on UI Tests, Test Plans, Performance Tests and Coverage.
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 }
}
}
See Apple Developer Documentation — isAccessibilitySize and Apple Developer Documentation — AnyLayout.
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 |
|
Replaces the sliding pin animation with a cross-fade. |
Reduce Transparency |
|
Draws the capture overlay on an opaque background instead of a translucent one. |
Increase Contrast |
|
Uses higher-contrast colors from the asset catalog and adds borders to tag chips. |
Differentiate Without Color |
|
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:
-
expose each chip as a toggle with a clear label and a selected state;
-
give Voice Control users short spoken names (
accessibilityInputLabels); -
announce the result of a change, since the list updates somewhere else on screen;
-
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()
}
}
}
See Apple Developer Documentation -- accessibilityInputLabels(_:), Apple Developer Documentation — AccessibilityFocusState and Apple Developer Documentation — AccessibilityNotification.Announcement.
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
UISupportsFullScreenInAssistiveAccesstoYESso the existing UI fills the space. -
Streamlined UI. Set
UISupportsAssistiveAccesstoYESand add anAssistiveAccessscene 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
}
}
See Apple Developer Documentation — AssistiveAccess and Apple Developer Documentation — Optimizing your app for Assistive Access.
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"
}
}
}
See Apple Developer Documentation -- performAccessibilityAudit(for:_:) and Apple Developer Documentation — XCUIAccessibilityAuditType.
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 |
|
Complete every common task with VoiceOver only; |
Voice Control |
Visible labels matching |
"Show names", then operate every control by voice. |
Larger Text |
Text styles, |
AX5 in Device Hub or Accessibility Inspector; Dynamic Type preview variants;
audit types |
Dark Interface |
Semantic colors and asset-catalog color sets with dark variants, |
Appearance override in Device Hub; check every screen, including sheets and alerts. |
Differentiate Without Color Alone |
|
Turn on the setting; review each status indicator; grayscale color filter. |
Sufficient Contrast |
High Contrast color variants, |
Audit type |
Reduced Motion |
|
Turn on Reduce Motion and run every animated flow. |
Captions |
AVKit’s |
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
-
Apple Developer Documentation — Enhancing the accessibility of your SwiftUI app
-
Apple Developer Documentation — accessibilityElement(children:)
-
Apple Developer Documentation -- accessibilityCustomContent(_:_:importance:)
-
Apple Developer Documentation — accessibilityRepresentation(representation:)
-
Apple Developer Documentation -- accessibilityAction(named:_:)
-
Apple Developer Documentation -- accessibilityAdjustableAction(_:)
-
Apple Developer Documentation -- accessibilityIdentifier(_:)
-
Apple Developer Documentation — accessibilityShowsLargeContentViewer()
-
Apple Developer Documentation — accessibilityReduceTransparency
-
Apple Developer Documentation — accessibilityDifferentiateWithoutColor
-
Apple Developer Documentation -- accessibilityInputLabels(_:)
-
Apple Developer Documentation — AccessibilityNotification.Announcement
-
Apple Developer Documentation — Optimizing your app for Assistive Access
-
Apple Developer Documentation — accessibilityAssistiveAccessEnabled
-
Apple Developer Documentation -- performAccessibilityAudit(for:_:)
-
Apple Developer Documentation — Configuring the environment of a simulated device
-
Apple Developer Documentation — Performing accessibility testing for your app
-
App Store Connect Help — Overview of Accessibility Nutrition Labels
-
WWDC25 — Evaluate your app for Accessibility Nutrition Labels