A portfolio-grade iOS AR demo showcasing clean MVVM architecture, protocol-driven dependency injection, and a unified abstraction for placing both .usdz and .reality 3D models into the real world via ARKit and RealityKit.
ARDemo lets you pick from three bundled 3D models and place them on real-world surfaces detected by ARKit. Once placed, you can move, rotate, and scale each model using touch gestures. The codebase is written to demonstrate architectural decisions as clearly as the AR features themselves — it is a showcase of how to build an iOS AR app, not just a proof that AR works.
┌───────────────────────────────────────────────────────┐
│ SwiftUI Views │
│ ModelSelectionView ─── ARScreen ─── AROverlayView │
└──────────────┬──────────────────────────┬────────────┘
│ @ObservedObject │ @ObservedObject
┌──────────────▼──────────┐ ┌───────────▼────────────┐
│ ModelSelectionViewModel │ │ ARViewModel │
│ • models: [ARModel] │ │ • sessionState │
│ • selectedModel │ │ • placementState │
└─────────────────────────┘ │ • isModelPlaced │
└─────┬─────┬───────┬─────┘
│ │ │
protocol │ │proto │ protocol
┌──────────────▼─┐ ┌─▼──────▼────┐
│ ARSessionManag-│ │ ModelLoadable│
│ ing │ │ │
└───────┬────────┘ └──────┬───────┘
│ │
┌───────▼────────┐ ┌──────▼───────┐
│ARSessionManager│ │ ModelLoader │
│ (ARSession) │ │ .usdz/.reali-│
└───────┬────────┘ │ ty dispatch │
│ └──────────────┘
┌───────▼────────┐
│ SceneManager │
│ (AnchorEntity) │
└───────┬────────┘
│
┌───────▼────────┐
│ RealityKit │
│ ARView + │
│ installGesture │
└────────────────┘
| Layer | Files | Role |
|---|---|---|
| Models | ARModel, ARPlacementState, ARSessionState |
Pure value types — no imports beyond Foundation |
| Scene | ModelLoader, SceneManager, ModelEntity+Extensions |
RealityKit manipulation; all @MainActor |
| ARSession | ARSessionManager, PlaneDetector |
ARKit session lifecycle; exposes Combine publishers |
| ViewModels | ARViewModel, ModelSelectionViewModel |
State machines; depend only on protocols |
| Views | ARViewContainer, AROverlayView, ModelSelectionView, Components |
SwiftUI + UIViewRepresentable |
| App | AppCoordinator, ARDemoApp |
Composition root — wires the dependency graph |
| Technology | Usage |
|---|---|
| ARKit | World tracking, plane detection (horizontal + vertical) |
| RealityKit | Entity rendering, AnchorEntity, installGestures |
| SwiftUI | All UI — NavigationStack, LazyVGrid, overlays |
| Combine | Session and plane-detection state publishers |
| Swift Concurrency | async/await entity loading; @MainActor isolation |
| Format | Extension | Loading API |
|---|---|---|
| Universal Scene Description (zip) | .usdz |
ModelEntity(named:in:) async initialiser |
| Reality Composer scene | .reality |
Entity(contentsOf:withName:) async initialiser |
ModelLoader presents a single load(model:) async throws -> Entity API regardless of format, selected by a switch on ARModelFormat. ViewModels and scene managers never see the file format — they work with Entity objects only.
| Model | Format | Anchor / Scene |
|---|---|---|
| Lunar Rover | .reality |
Scene name "Rover Flow" |
| Toy Biplane | .usdz |
N/A |
| Toy Car | .usdz |
N/A |
| Lunar Habitat | .reality |
Scene name "Flow" |
- Plane detection — scans for horizontal and vertical surfaces before allowing placement
- Model selection — 2-column grid with format badges (USDZ / REALITY)
- Preview placement — model appears at tap location before confirmation
- Confirm / Cancel — explicit confirmation step prevents accidental placements
- Drag to move — slide placed model along detected planes
- Rotate — twist gesture rotates around Y axis
- Pinch to scale — uniform scale clamped between 0.1× and 3.0×
- Reset — clears the scene and returns to plane scanning
- Session lifecycle — pauses on app background, resumes on foreground
- Error handling — typed
ModelLoadErrorsurfaced in the overlay
ARDemo/
├── App/
│ ├── ARDemoApp.swift ← @main entry point
│ └── AppCoordinator.swift ← composition root / navigation
├── ARSession/
│ ├── ARSessionManager.swift ← owns ARSession lifecycle; Combine publisher
│ └── PlaneDetector.swift ← tracks ARPlaneAnchor additions/removals
├── Scene/
│ ├── ModelLoader.swift ← unified async API for .usdz and .reality
│ ├── ModelEntity+Extensions.swift ← uniformScale, rotateAroundY, ScaleConstraints
│ └── SceneManager.swift ← places/removes AnchorEntity in the scene
├── ViewModels/
│ ├── ModelSelectionViewModel.swift
│ └── ARViewModel.swift
├── Views/
│ ├── ModelSelectionView.swift
│ ├── ARViewContainer.swift ← UIViewRepresentable + ARScreen
│ ├── AROverlayView.swift
│ └── Components/
│ ├── ModelThumbnailView.swift
│ └── PlacementConfirmationView.swift
├── Models/
│ ├── ARModel.swift ← ARModel struct + static catalog
│ ├── ARPlacementState.swift
│ └── ARSessionState.swift
└── Resources/
├── LunarRover_English.reality
├── toy_biplane_realistic.usdz
└── toy_car.usdz
ARDemoTests/
├── ARModelTests.swift
├── ModelLoaderTests.swift
├── ARViewModelTests.swift
├── SceneManagerTests.swift
└── Mocks/
├── MockModelLoader.swift
├── MockARSessionManager.swift
└── MockSceneManager.swift
Note: ARKit requires a physical device. The iOS Simulator does not support camera access or ARKit. You need an iPhone (A12 Bionic or later) to run this app.
- Clone the repository
- Open
ARDemo.xcodeprojin Xcode 15 or later - Select your physical iPhone as the run destination
- Build and run (
⌘R) - Grant camera permission when prompted
- Point the camera at a flat surface and move slowly to detect planes
| Requirement | Minimum |
|---|---|
| iOS | 18.0 |
| Device | iPhone with A12 Bionic or later |
| Xcode | 15.0+ |
| Swift | 5.9+ |
All inter-layer boundaries use protocols (ModelLoadable, ARSessionManaging, SceneManaging). No ViewModel instantiates its own collaborators — they are injected by AppCoordinator. This makes every ViewModel unit-testable without a real device, real camera, or real RealityKit session.
.usdz files use ModelEntity(named:in:) — a high-level async initialiser that handles mesh decompression. .reality files require Entity(contentsOf:withName:) followed by a findEntity(named:) tree walk to locate the named scene. These are fundamentally different loading paths. ModelLoader contains the only switch on ARModelFormat in the entire codebase: all other code receives an Entity and is completely format-agnostic.
In iOS 17+ SDKs, all Entity loading and property access is @MainActor-isolated. Protocols that involve RealityKit types (ModelLoadable, SceneManaging) are therefore declared @MainActor so the compiler enforces isolation at the protocol level rather than at each call site.
The UIViewRepresentable wrapper is named ARViewContainer to avoid shadowing RealityKit.ARView. Without this distinction, every file that uses the type-level name ARView would need a module qualifier (RealityKit.ARView), which is noisy and error-prone.
ARSessionManager could expose plane detection directly, but separating it into PlaneDetector keeps single-responsibility clean: ARSessionManager owns the session lifecycle; PlaneDetector owns the set of known planes and the hasDetectedPlane publisher. ARViewModel subscribes to PlaneDetector.hasDetectedPlanePublisher independently.
Tests live in ARDemoTests/ and use XCTest:
| Test class | What it tests |
|---|---|
ARModelTests |
Catalog count, correct formats, ID uniqueness, Equatable semantics |
ModelLoaderTests |
Success path, fileNotFound, loadFailed, anchorNotFound error descriptions |
ARViewModelTests |
Initial state, startSession, plane-detection state transitions, resetScene, pauseSession |
SceneManagerTests |
placeEntity sets currentPlacedEntity, removeAllEntities clears it |
All tests run against mock collaborators — no device, no ARKit, no RealityKit engine required. The mocks are in ARDemoTests/Mocks/ and conform to the same protocols as the production implementations.
Ayush Kumar Sethi
aks5686.github.io · github.com/aks5686