Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/accessibility.md
2025-11-29 18:28:37 +08:00

9.7 KiB

Accessibility

VoiceOver, Dynamic Type, and inclusive design for iOS apps.

VoiceOver Support

Basic Labels

struct ItemRow: View {
    let item: Item

    var body: some View {
        HStack {
            Image(systemName: item.icon)
                .accessibilityHidden(true)  // Icon is decorative

            VStack(alignment: .leading) {
                Text(item.name)
                Text(item.date, style: .date)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            if item.isCompleted {
                Image(systemName: "checkmark")
                    .accessibilityHidden(true)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(item.name), \(item.isCompleted ? "completed" : "incomplete")")
        .accessibilityHint("Double tap to view details")
    }
}

Custom Actions

struct ItemRow: View {
    let item: Item
    let onDelete: () -> Void
    let onToggle: () -> Void

    var body: some View {
        HStack {
            Text(item.name)
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel(item.name)
        .accessibilityAction(named: "Toggle completion") {
            onToggle()
        }
        .accessibilityAction(named: "Delete") {
            onDelete()
        }
    }
}

Traits

Text("Important Notice")
    .accessibilityAddTraits(.isHeader)

Button("Submit") { }
    .accessibilityAddTraits(.startsMediaSession)

Image("photo")
    .accessibilityAddTraits(.isImage)

Link("Learn more", destination: url)
    .accessibilityAddTraits(.isLink)

Toggle("Enable", isOn: $isEnabled)
    .accessibilityAddTraits(isEnabled ? .isSelected : [])

Announcements

// Announce changes
func saveCompleted() {
    AccessibilityNotification.Announcement("Item saved successfully").post()
}

// Screen change
func showNewScreen() {
    AccessibilityNotification.ScreenChanged(nil).post()
}

// Layout change
func expandSection() {
    isExpanded = true
    AccessibilityNotification.LayoutChanged(nil).post()
}

Rotor Actions

struct ArticleView: View {
    @State private var fontSize: CGFloat = 16

    var body: some View {
        Text(article.content)
            .font(.system(size: fontSize))
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    fontSize = min(fontSize + 2, 32)
                case .decrement:
                    fontSize = max(fontSize - 2, 12)
                @unknown default:
                    break
                }
            }
    }
}

Dynamic Type

Scaled Fonts

// System fonts scale automatically
Text("Title")
    .font(.title)

Text("Body")
    .font(.body)

// Custom fonts with scaling
Text("Custom")
    .font(.custom("Helvetica", size: 17, relativeTo: .body))

// Fixed size (use sparingly)
Text("Fixed")
    .font(.system(size: 12).fixed())

Scaled Metrics

struct IconButton: View {
    @ScaledMetric var iconSize: CGFloat = 24
    @ScaledMetric(relativeTo: .body) var spacing: CGFloat = 8

    var body: some View {
        HStack(spacing: spacing) {
            Image(systemName: "star")
                .font(.system(size: iconSize))
            Text("Favorite")
        }
    }
}

Line Limits with Accessibility

Text(item.description)
    .lineLimit(3)
    .truncationMode(.tail)
    // But allow more for accessibility sizes
    .dynamicTypeSize(...DynamicTypeSize.accessibility1)

Testing Dynamic Type

#Preview("Default") {
    ContentView()
}

#Preview("Large") {
    ContentView()
        .environment(\.sizeCategory, .accessibilityLarge)
}

#Preview("Extra Extra Large") {
    ContentView()
        .environment(\.sizeCategory, .accessibilityExtraExtraLarge)
}

Reduce Motion

struct AnimatedView: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var isExpanded = false

    var body: some View {
        VStack {
            // Content
        }
        .animation(reduceMotion ? .none : .spring(), value: isExpanded)
    }
}

// Alternative animations
struct TransitionView: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var showDetail = false

    var body: some View {
        VStack {
            if showDetail {
                DetailView()
                    .transition(reduceMotion ? .opacity : .slide)
            }
        }
        .animation(.default, value: showDetail)
    }
}

Color and Contrast

Semantic Colors

// Use semantic colors that adapt
Text("Primary")
    .foregroundStyle(.primary)

Text("Secondary")
    .foregroundStyle(.secondary)

Text("Tertiary")
    .foregroundStyle(.tertiary)

// Error state
Text("Error")
    .foregroundStyle(.red)  // Use semantic red, not custom

Increase Contrast

struct ContrastAwareView: View {
    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
    @Environment(\.accessibilityIncreaseContrast) private var increaseContrast

    var body: some View {
        HStack {
            Circle()
                .fill(increaseContrast ? .primary : .secondary)

            if differentiateWithoutColor {
                // Add non-color indicator
                Image(systemName: "checkmark")
            }
        }
    }
}

Color Blind Support

struct StatusIndicator: View {
    let status: Status
    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor

    var body: some View {
        HStack {
            Circle()
                .fill(status.color)
                .frame(width: 10, height: 10)

            if differentiateWithoutColor {
                Image(systemName: status.icon)
            }

            Text(status.label)
        }
    }
}

enum Status {
    case success, warning, error

    var color: Color {
        switch self {
        case .success: return .green
        case .warning: return .orange
        case .error: return .red
        }
    }

    var icon: String {
        switch self {
        case .success: return "checkmark.circle"
        case .warning: return "exclamationmark.triangle"
        case .error: return "xmark.circle"
        }
    }

    var label: String {
        switch self {
        case .success: return "Success"
        case .warning: return "Warning"
        case .error: return "Error"
        }
    }
}

Focus Management

Focus State

struct LoginView: View {
    @State private var username = ""
    @State private var password = ""
    @FocusState private var focusedField: Field?

    enum Field {
        case username, password
    }

    var body: some View {
        Form {
            TextField("Username", text: $username)
                .focused($focusedField, equals: .username)
                .submitLabel(.next)
                .onSubmit {
                    focusedField = .password
                }

            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)
                .submitLabel(.done)
                .onSubmit {
                    login()
                }
        }
        .onAppear {
            focusedField = .username
        }
    }
}

Accessibility Focus

struct AlertView: View {
    @AccessibilityFocusState private var isAlertFocused: Bool

    var body: some View {
        VStack {
            Text("Important Alert")
                .accessibilityFocused($isAlertFocused)
        }
        .onAppear {
            isAlertFocused = true
        }
    }
}

Button Shapes

struct AccessibleButton: View {
    @Environment(\.accessibilityShowButtonShapes) private var showButtonShapes

    var body: some View {
        Button("Action") { }
            .padding()
            .background(showButtonShapes ? Color.accentColor.opacity(0.1) : Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}

Smart Invert Colors

Image("photo")
    .accessibilityIgnoresInvertColors()  // Photos shouldn't invert

Audit Checklist

VoiceOver

  • All interactive elements have labels
  • Decorative elements are hidden
  • Custom actions for swipe gestures
  • Headings marked correctly
  • Announcements for dynamic changes

Dynamic Type

  • All text uses dynamic fonts
  • Layout adapts to large sizes
  • No text truncation at accessibility sizes
  • Touch targets remain accessible (44pt minimum)

Color and Contrast

  • 4.5:1 contrast ratio for text
  • Information not conveyed by color alone
  • Works with Increase Contrast
  • Works with Smart Invert

Motion

  • Animations respect Reduce Motion
  • No auto-playing animations
  • Alternative interactions for gesture-only features

General

  • All functionality available via VoiceOver
  • Logical focus order
  • Error messages are accessible
  • Time limits are adjustable

Testing Tools

Accessibility Inspector

  1. Open Xcode > Open Developer Tool > Accessibility Inspector
  2. Point at elements to inspect labels, traits, hints
  3. Run audit for common issues

VoiceOver Practice

  1. Settings > Accessibility > VoiceOver
  2. Use with your app
  3. Navigate by swiping, double-tap to activate

Voice Control

  1. Settings > Accessibility > Voice Control
  2. Test all interactions with voice commands

Xcode Previews

#Preview {
    ContentView()
        .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
        .environment(\.accessibilityReduceMotion, true)
        .environment(\.accessibilityDifferentiateWithoutColor, true)
}