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.
| # | Violation | Detection method |
|---|---|---|
| 1 | Color.white background — breaks dark mode | Toggle dark mode in simulator (Cmd+Shift+A) |
| 2 | 24×24pt close button — below 44pt minimum tap target | Visual inspection; Accessibility Inspector Audit |
| 3 | Gray text at 40% opacity on white — fails WCAG contrast | Accessibility Inspector → Audit or Contrast app |
| 4 | Image with no accessibilityLabel | Turn on VoiceOver; swipe to image; hear “image” with no description |
| 5 | TabView on iPad — should be NavigationSplitView | Run on iPad simulator; visually wrong |
| 6 | Custom slider — has no accessibility traits, system Slider would work | VoiceOver 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
NavigationSplitViewfor 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:
- Re-run Accessibility Inspector Audit — should report 0 issues
- Re-run with VoiceOver — every interactive element announces meaningfully
- Toggle dark mode — UI adapts correctly
- Toggle AX5 Dynamic Type — text grows, layout reflows without overflow
- Run on iPad Pro 13“ — sidebar+detail layout shows
- Run on iPhone 16 — same code now shows tab-bar equivalent collapse
Stretch goals
- Add Switch Control support testing (
Settings → Accessibility → Switch Controlon 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
NavigationSplitViewon 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.