Lab 3.2 — HIG & Accessibility Audit

Duration: ~75 minutes Prereqs: Xcode 16+, Accessibility Inspector (bundled with Xcode), VoiceOver enabled on simulator

Goal

You’ll be given a starter SwiftUI app — ShoppyApp — that contains 6 deliberate HIG and accessibility violations. Your job: find every one using Apple’s tools (Accessibility Inspector, VoiceOver, Environment Overrides), then fix each one. By the end you’ll know how to audit any iOS app for accessibility correctness.

The starter app

Create a new SwiftUI app called ShoppyApp. Replace ContentView.swift with the following — do not fix anything yet, this is the source of your audit:

import SwiftUI

struct ContentView: View {
    @State private var quantity: Double = 1

    var body: some View {
        TabView {
            ProductScreen(quantity: $quantity)
                .tabItem { Label("Shop", systemImage: "bag") }
            CartScreen()
                .tabItem { Label("Cart", systemImage: "cart") }
            ProfileScreen()
                .tabItem { Label("Profile", systemImage: "person") }
        }
    }
}

struct ProductScreen: View {
    @Binding var quantity: Double
    @State private var showDetails = false

    var body: some View {
        ZStack(alignment: .topTrailing) {
            Color.white.ignoresSafeArea()  // ❌ Violation #1

            VStack(alignment: .leading, spacing: 16) {
                Image("hero-shoe")  // ❌ Violation #4 (no accessibility label)
                    .resizable()
                    .scaledToFit()
                    .frame(height: 240)

                Text("Premium Runner")
                    .font(.title2)
                    .fontWeight(.bold)

                Text("Lightweight performance shoe.")
                    .font(.body)
                    .foregroundStyle(Color.gray.opacity(0.4))  // ❌ Violation #3

                Text("$159")
                    .font(.title)

                // ❌ Violation #6: custom slider when system Slider works
                CustomQuantitySlider(value: $quantity)

                Button(action: { /* add */ }) {
                    Text("Add to Cart")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.blue)
                        .foregroundStyle(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
            }
            .padding()

            // ❌ Violation #2: 24x24 close button with no hit area
            Button(action: { showDetails = false }) {
                Image(systemName: "xmark")
                    .frame(width: 24, height: 24)
            }
            .padding(8)
        }
    }
}

struct CartScreen: View {
    var body: some View {
        Text("Cart")
    }
}

struct ProfileScreen: View {
    var body: some View {
        Text("Profile")
    }
}

// ❌ Violation #6: hand-rolled slider with no accessibility traits
struct CustomQuantitySlider: View {
    @Binding var value: Double
    var body: some View {
        HStack {
            Text("Qty:")
            Rectangle()
                .fill(Color.blue)
                .frame(width: CGFloat(value) * 30, height: 8)
                .onTapGesture { value += 1 }
            Spacer()
        }
    }
}

Run the app on iPad Pro 13“ simulator (this surfaces violation #5: the tab bar is wrong UI on iPad).

The 6 violations to find and fix

You should not look at this list before running the app. Try to discover each issue first using the tools below. After ~45 minutes, compare against this list and patch anything you missed.

#ViolationDetection method
1Color.white background — breaks dark modeToggle dark mode in simulator (Cmd+Shift+A)
224×24pt close button — below 44pt minimum tap targetVisual inspection; Accessibility Inspector Audit
3Gray text at 40% opacity on white — fails WCAG contrastAccessibility Inspector → Audit or Contrast app
4Image with no accessibilityLabelTurn on VoiceOver; swipe to image; hear “image” with no description
5TabView on iPad — should be NavigationSplitViewRun on iPad simulator; visually wrong
6Custom slider — has no accessibility traits, system Slider would workVoiceOver doesn’t announce as slider; can’t adjust with rotor

Tools workflow

Tool 1 — Environment Overrides (in-simulator)

In Xcode while debugging, click the small Environment Overrides toggle at the bottom of the simulator/debug toolbar. Toggle:

  • Light/Dark appearance → reveals violation #1
  • Text Size: AX5 → reveals layout issues with Dynamic Type
  • Increased Contrast: On → tests high-contrast variants
  • Reduce Motion: On → reveals heavy animations

Tool 2 — Accessibility Inspector

Open Xcode → Open Developer Tool → Accessibility Inspector. Choose your simulator as target. Click Audit (the checkmark icon at the top).

The Audit runs automated checks:

  • Contrast ratios (reveals #3)
  • Hit-region sizes (reveals #2)
  • Missing accessibility labels (reveals #4)
  • Dynamic Type breakage (reveals overflow with AX5)

For each violation in the Audit panel, you can click “Show in Simulator” to highlight the offending view.

Tool 3 — VoiceOver

On simulator: Settings → Accessibility → VoiceOver → On. Or use the keyboard shortcut from simulator menu.

Once VoiceOver is on:

  • Single tap to select; the system speaks the element
  • Swipe right with one finger to move to next element
  • Double-tap to activate

What to listen for:

  • Every interactive element has a meaningful label (not “Button” alone)
  • Images convey content via label (or are correctly marked decorative)
  • Custom controls announce their type (Slider, Button, Toggle) — violation #6 fails this

Tool 4 — iPad-specific testing

Run on iPad Pro 13“ simulator. Rotate to landscape (Cmd+→). Observe:

  • Tab bar at bottom on a 1366pt-wide screen looks comically narrow
  • iPad apps in 2025 should use NavigationSplitView for 3-pane layout

The fixes

Fix #1 — Background

Color(.systemBackground).ignoresSafeArea()

Or just drop the Color.white and let the default system background show.

Fix #2 — Tap target

Button(action: { showDetails = false }) {
    Image(systemName: "xmark")
        .frame(width: 44, height: 44)         // ← expand to 44pt
        .contentShape(Rectangle())             // ← expand hit area
}
.padding(8)

Or keep the 24pt icon but expand the hit region:

Image(systemName: "xmark")
    .frame(width: 24, height: 24)
    .padding(10)                               // pads to 44pt total
    .contentShape(Rectangle())

Fix #3 — Contrast

Text("Lightweight performance shoe.")
    .font(.body)
    .foregroundStyle(.secondary)               // ← Apple's tested-contrast token

.secondary is guaranteed ≥4.5:1 by Apple in both light and dark modes.

Fix #4 — Image label

Image("hero-shoe")
    .resizable()
    .scaledToFit()
    .frame(height: 240)
    .accessibilityLabel("Premium Runner shoe, side view in white")

Or mark decorative if it adds no info:

Image("hero-shoe")
    ...
    .accessibilityHidden(true)

Fix #5 — iPad layout

struct ContentView: View {
    @State private var quantity: Double = 1
    @State private var selection: AppSection? = .shop

    var body: some View {
        NavigationSplitView {
            List(selection: $selection) {
                NavigationLink(value: AppSection.shop) {
                    Label("Shop", systemImage: "bag")
                }
                NavigationLink(value: AppSection.cart) {
                    Label("Cart", systemImage: "cart")
                }
                NavigationLink(value: AppSection.profile) {
                    Label("Profile", systemImage: "person")
                }
            }
        } detail: {
            switch selection {
            case .shop: ProductScreen(quantity: $quantity)
            case .cart: CartScreen()
            case .profile: ProfileScreen()
            case nil: Text("Select a section")
            }
        }
    }
}

enum AppSection: Hashable { case shop, cart, profile }

NavigationSplitView automatically collapses to a single column on iPhone and a TabView-equivalent narrow window. One layout, both platforms.

Fix #6 — Use the system Slider

VStack(alignment: .leading) {
    Text("Quantity: \(Int(quantity))")
    Slider(value: $quantity, in: 1...10, step: 1)
        .accessibilityLabel("Quantity")
        .accessibilityValue("\(Int(quantity))")
}

System Slider has full VoiceOver support, rotor adjustment, keyboard control. Free.

Verification pass

After all 6 fixes:

  1. Re-run Accessibility Inspector Audit — should report 0 issues
  2. Re-run with VoiceOver — every interactive element announces meaningfully
  3. Toggle dark mode — UI adapts correctly
  4. Toggle AX5 Dynamic Type — text grows, layout reflows without overflow
  5. Run on iPad Pro 13“ — sidebar+detail layout shows
  6. Run on iPhone 16 — same code now shows tab-bar equivalent collapse

Stretch goals

  • Add Switch Control support testing (Settings → Accessibility → Switch Control on a real device)
  • Add localization — translate all visible strings to Spanish, ship with Localizable.xcstrings. Verify Dynamic Type still works with longer strings (German is the harder test).
  • Add Reduce Motion branch — if you add animations, gate them on accessibilityReduceMotion.
  • Run on Mac Catalyst — does the iPad layout work? What needs adjustment?

Acceptance criteria

  • All 6 violations found and fixed
  • Accessibility Inspector Audit reports 0 issues on all 3 screens
  • App works correctly in light and dark mode
  • App reflows correctly at AX5 Dynamic Type
  • App uses NavigationSplitView on iPad
  • VoiceOver navigation is meaningful end-to-end
  • No accessibility-hostile custom controls (use system controls where possible)

What you’ve learned

You now own the audit playbook. Every iOS app you ship — yours, your team’s, an inherited codebase — can be put through this exact loop in under an hour. Accessibility is not “extra credit”; it’s part of “done.” This lab is the diff between an iOS engineer and an iOS engineer who ships products people actually trust.

Real numbers: 15% of users have a disability. App Store search and Editor’s Choice favor accessible apps. ADA lawsuits against inaccessible apps are real and cost six figures. This loop is the cheapest insurance you can buy.


Next: Lab 3.3 — Palette from Brief