WWDC21 Challenge: VoiceOver Maze
At a glance
| Item | Summary |
|---|---|
| Purpose | Build a VoiceOver-first branching maze using virtual accessibility elements instead of visible maze controls. |
| Architecture | An instruction controller presents a maze controller; the maze derives stage text and decisions from lookup tables. |
| State strategy | The current stage is implicit in each generated button element’s nextStage; activation rebuilds the accessibility tree. |
| Boundary | Code-light single UIKit target with no persistence, extension, or service layer. |
Project structure
WWDC_VoiceOverMaze/
├── ViewController.swift
├── MazeViewController.swift
├── VoiceOverActivatableButton.swift
├── AppDelegate.swift
└── SceneDelegate.swift
Most of the app’s behavior is deliberately concentrated in MazeViewController for the challenge.
Overall architecture
flowchart LR
Intro["ViewController"] --> Start["VoiceOverActivatableButton"]
Start --> Maze["MazeViewController"]
Maze --> Text["instructionString(stage)"]
Maze --> Decisions["buttonInfo(stage)"]
Decisions --> AX["ButtonAccessibilityElement[]"]
AX --> Maze
Maze --> Notify["UIAccessibility layout change"]
Reference code
WWDC_VoiceOverMaze/MazeViewController.swift:245 — a stage is rendered as a new virtual accessibility tree (abridged).
private func initializeStage(_ stage: Int) {
let instructionString = self.instructionString(stage)
let buttons = self.buttonInfo(stage)
var elements = [UIAccessibilityElement]()
for buttonInfo in buttons {
let element = ButtonAccessibilityElement(accessibilityContainer: self)
element.buttonInfo = buttonInfo
element.mazeController = self
elements.append(element)
}
view.accessibilityElements = elements
}Ownership and state
classDiagram
ViewController *-- VoiceOverActivatableButton
VoiceOverActivatableButton *-- Closure : activatedHandler
MazeViewController *-- UIAccessibilityElement : current stage elements
ButtonAccessibilityElement o-- MazeViewController : weak
ButtonAccessibilityElement *-- ButtonInfo
Ownership evidence
WWDC_VoiceOverMaze/MazeViewController.swift:39 — virtual buttons retain decision data but only weakly reference their coordinator.
class ButtonAccessibilityElement: UIAccessibilityElement {
var buttonInfo: ButtonInfo?
weak var mazeController: MazeViewController?
override func accessibilityActivate() -> Bool {
mazeController?.initializeStage(buttonInfo.nextStage)
return true
}
}The controller replaces view.accessibilityElements for every stage. The weak back-reference prevents those child elements from retaining the controller. The intro button’s command closure captures its controller directly; the sample does not add a weak capture.
Class and protocol design
The design subclasses framework types instead of defining protocols: VoiceOverActivatableButton: UIButton translates VoiceOver activation into a closure, while nested ButtonAccessibilityElement translates activation into a stage transition. ButtonInfo is a value command containing a decision and destination.
Access control
| Symbol | Access | Rationale |
|---|---|---|
setup() |
private |
Only the intro controller’s initializers assemble its views (WWDC_VoiceOverMaze/ViewController.swift:27). |
| Stage lookup and rendering helpers | private |
Only MazeViewController may interpret/rebuild maze state (WWDC_VoiceOverMaze/MazeViewController.swift:57). |
ButtonInfo and custom control types |
internal default | They collaborate across the small app target. |
| Public/file-private API | none | There is no module boundary or same-file extension requirement. |
Logic ownership and placement
| Logic | Owner | Placement reason |
|---|---|---|
| Intro layout and presentation | ViewController |
It owns the start-screen controls. |
| Stage graph and transition rendering | MazeViewController |
Text, decisions, and accessibility frames change atomically. |
| Generic activation callback | VoiceOverActivatableButton |
Converts a UIKit hook into a reusable command (WWDC_VoiceOverMaze/VoiceOverActivatableButton.swift:10). |
| Winning screen | MazeViewController |
Terminal stage shares the maze’s presentation lifecycle. |
Design patterns
| Pattern | Evidence | Purpose |
|---|---|---|
| Table-driven state machine | Stage-indexed text and decisions | Expresses branching without many screen classes. |
| Command object/value | ButtonInfo |
Carries transition intent into generated elements. |
| Custom accessibility adapter | Virtual element overrides activation | Makes nonvisual navigation the primary interaction. |
| Command closure | Start button’s activatedHandler |
Avoids a one-method delegate for one action. |
Naming conventions
ButtonInfo.Decisionnames domain choices;nextStagenames transition data.- UIKit subclasses carry explicit role suffixes (
ViewController,Button,AccessibilityElement). - Builder methods start with
configureorinitialize; lookup helpers use domain nouns.
Architecture takeaways
- A stage graph can generate the accessibility hierarchy as derived UI.
- Keep child-to-owner references weak when the owner retains the children.
- Use value commands for transitions and a controller for atomic tree replacement.
- This sample is intentionally VoiceOver-first and compact; it is not a general game engine.
Source map
| Source | Role |
|---|---|
WWDC_VoiceOverMaze/AppDelegate.swift:10 |
App entry point |
WWDC_VoiceOverMaze/ViewController.swift:50 |
Start command wiring |
WWDC_VoiceOverMaze/MazeViewController.swift:10 |
Decision value |
WWDC_VoiceOverMaze/MazeViewController.swift:125 |
Maze lifecycle |
WWDC_VoiceOverMaze/MazeViewController.swift:257 |
Virtual element creation |
WWDC_VoiceOverMaze/VoiceOverActivatableButton.swift:14 |
VoiceOver command hook |