How to Build a 3D Model Viewer in SwiftUI
Use SwiftUI's built-in SceneView from SceneKit to render an interactive, pinch-and-rotate 3D model from a bundled .usdz or .scn file in two lines of code.
For physically-based RealityKit materials, wrap ARView in a UIViewRepresentable.
import SwiftUI
import SceneKit
struct ModelViewer: View {
var body: some View {
SceneView(
scene: SCNScene(named: "robot.usdz"),
options: [
.allowsCameraControl,
.autoenablesDefaultLighting,
.rendersContinuously
]
)
.ignoresSafeArea()
.background(Color.black)
}
}
#Preview {
ModelViewer()
}
Full implementation
The viewer below loads a USDZ asset asynchronously on a background thread, shows a ProgressView while loading, and falls back to a placeholder when the file is missing or malformed.
A custom ViewerControlsOverlay adds a reset-camera button and a wireframe toggle, demonstrating how to drive SceneKit state from SwiftUI without breaking the render loop.
The implementation deliberately avoids ARKit so the viewer works on the Simulator.
import SwiftUI
import SceneKit
// MARK: - View model
@Observable
final class ModelViewerVM {
var scene: SCNScene?
var isLoading = false
var errorMessage: String?
var showWireframe = false
func load(named filename: String) {
isLoading = true
errorMessage = nil
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
guard let url = Bundle.main.url(
forResource: filename,
withExtension: nil
) else {
await MainActor.run {
self.errorMessage = "Asset "\(filename)" not found in bundle."
self.isLoading = false
}
return
}
do {
let loaded = try SCNScene(url: url, options: [
.checkConsistency: true,
.convertToYUp: true
])
await MainActor.run {
self.scene = loaded
self.isLoading = false
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
}
func resetCamera() {
// Trigger a camera reset by briefly nil-ing and re-setting; handled via SceneView pointOfView
let saved = scene
scene = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.scene = saved
}
}
func applyWireframe(_ on: Bool, to scene: SCNScene?) {
scene?.rootNode.enumerateChildNodes { node, _ in
node.geometry?.firstMaterial?.fillMode = on ? .lines : .fill
}
}
}
// MARK: - Main view
struct ModelViewerScreen: View {
@State private var vm = ModelViewerVM()
let filename: String
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if vm.isLoading {
ProgressView("Loading model…")
.foregroundStyle(.white)
.tint(.white)
} else if let msg = vm.errorMessage {
ContentUnavailableView(
"Couldn't Load Model",
systemImage: "cube.transparent",
description: Text(msg)
)
.foregroundStyle(.white)
} else if let scene = vm.scene {
SceneView(
scene: scene,
pointOfView: nil,
options: [
.allowsCameraControl,
.autoenablesDefaultLighting,
.rendersContinuously
],
preferredFramesPerSecond: 60,
antialiasingMode: .multisampling4X
)
.ignoresSafeArea()
.overlay(alignment: .bottomTrailing) {
ViewerControlsOverlay(vm: vm, scene: scene)
.padding(20)
}
}
}
.navigationTitle("3D Viewer")
.navigationBarTitleDisplayMode(.inline)
.task { vm.load(named: filename) }
}
}
// MARK: - Controls overlay
struct ViewerControlsOverlay: View {
let vm: ModelViewerVM
let scene: SCNScene
var body: some View {
VStack(spacing: 12) {
Button {
vm.resetCamera()
} label: {
Label("Reset", systemImage: "arrow.counterclockwise")
.labelStyle(.iconOnly)
.padding(10)
.background(.ultraThinMaterial, in: Circle())
}
.accessibilityLabel("Reset camera")
Button {
let next = !vm.showWireframe
vm.showWireframe = next
vm.applyWireframe(next, to: scene)
} label: {
Label("Wireframe",
systemImage: vm.showWireframe ? "cube.fill" : "cube")
.labelStyle(.iconOnly)
.padding(10)
.background(.ultraThinMaterial, in: Circle())
}
.accessibilityLabel(vm.showWireframe ? "Disable wireframe" : "Enable wireframe")
}
.foregroundStyle(.white)
}
}
// MARK: - Preview
#Preview("Robot USDZ") {
NavigationStack {
ModelViewerScreen(filename: "robot.usdz")
}
}
#Preview("Missing file") {
NavigationStack {
ModelViewerScreen(filename: "does_not_exist.usdz")
}
}
How it works
-
Async asset loading.
Task.detached(priority: .userInitiated)moves the blockingSCNScene(url:options:)call off the main thread. The.checkConsistencyand.convertToYUpoptions ensure USDZ files with mixed axis conventions render upright. -
@Observable view model.
iOS 17's
@Observablemacro replacesObservableObject. Only properties actually read by the view trigger re-renders, so loading state, error, and scene changes each invalidate minimally. -
SceneView options.
.allowsCameraControlwires the built-in pinch, rotate, and pan gesture recognisers..autoenablesDefaultLightingadds an omnidirectional light when no lights are present in the scene graph, so freshly exported USDZ files look correct without manual setup. -
Wireframe toggle.
applyWireframe(_:to:)walks the scene's node hierarchy withenumerateChildNodesand flips every material'sfillModebetween.linesand.fill— a handy debug tool during development. -
ContentUnavailableView fallback.
Introduced in iOS 17,
ContentUnavailableViewgives users a well-structured empty state without custom layout code, following Human Interface Guidelines for failed loads.
Variants
RealityKit renderer via UIViewRepresentable
When you need PBR materials, shadows, or environment lighting that SceneKit can't match, wrap ARView (set to .nonAR mode so it works without a camera feed).
import SwiftUI
import RealityKit
struct RealityModelView: UIViewRepresentable {
let usdzName: String
func makeUIView(context: Context) -> ARView {
let view = ARView(frame: .zero)
// Non-AR mode — no camera, no session
view.environment.background = .color(.black)
view.cameraMode = .nonAR
view.automaticallyConfigureSession = false
Task {
guard let url = Bundle.main.url(
forResource: usdzName, withExtension: nil) else { return }
let entity = try await ModelEntity(contentsOf: url)
entity.generateCollisionShapes(recursive: true)
let anchor = AnchorEntity(world: .zero)
anchor.addChild(entity)
view.scene.addAnchor(anchor)
// Position camera to frame the model
view.camera.transform.translation = [0, 0.3, 1.2]
}
return view
}
func updateUIView(_ uiView: ARView, context: Context) {}
}
#Preview {
RealityModelView(usdzName: "robot.usdz")
.ignoresSafeArea()
}
Loading a remote USDZ over HTTPS
Replace the Bundle.main.url lookup with a URLSession download to a temporary directory, then pass the local file URL to SCNScene(url:options:). Cache the downloaded file with FileManager in cachesDirectory so repeat loads are instant. Ensure your Info.plist does not set NSAllowsArbitraryLoads — use a domain exception or HTTPS exclusively.
Common pitfalls
-
iOS 17 vs iOS 18 API confusion.
RealityViewandModel3Dare visionOS/iOS 18+ APIs. On iOS 17 you must wrapARViewin aUIViewRepresentableto use RealityKit — the compiler won't warn you until you set the deployment target to 17. -
Main-thread blocking on large assets.
Calling
SCNScene(named:)synchronously on the main actor for files larger than ~5 MB will freeze the UI. Always useTask.detachedorSCNScene(url:options:)with a background executor as shown in the full implementation. -
Missing VoiceOver semantics.
SceneViewandARVieware completely transparent to VoiceOver by default. Apply.accessibilityLabel("3D model of \(filename)")and.accessibilityHint("Pinch to zoom, drag to rotate")to the wrapping view so screenreader users understand what is on screen. -
Simulator limitations.
The Simulator does not support Metal on all CI machines. Gate RealityKit-heavy code behind
#if !targetEnvironment(simulator)or use SceneKit as a fallback renderer to keep Xcode previews and CI green.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a 3D model viewer in SwiftUI for iOS 17+. Use SceneKit SceneView as the primary renderer and a UIViewRepresentable ARView wrapper for RealityKit as a variant. Support USDZ files bundled in the app target. Include camera reset and wireframe toggle controls. Make it accessible (VoiceOver labels and hints). Handle loading, error, and success states with ContentUnavailableView. Add a #Preview with a realistic sample asset name.
In the Soarias Build phase, paste this prompt into the active session to scaffold the viewer and its view model, then use the Refine step to swap in your actual asset filenames and branding.
Related
FAQ
Does this work on iOS 16?
SceneView is available from iOS 15, so the SceneKit approach compiles for iOS 16. However, the @Observable macro and ContentUnavailableView require iOS 17. For iOS 16 support, replace @Observable with @ObservableObject / @Published and swap ContentUnavailableView for a custom VStack placeholder.
How do I export a USDZ from Blender or Reality Composer Pro?
In Blender, install the official Apple USDZ Tools or export as .glb then convert with xcrun usdz_converter model.glb model.usdz in Terminal. Reality Composer Pro (Xcode 15+) exports .usdz directly and validates materials against the RealityKit PBR spec. Drag the resulting file into your Xcode project's asset catalog or directly into the target's bundle.
What is the UIKit equivalent?
In UIKit you'd add an SCNView subview directly, set its scene property, and enable allowsCameraControl. For RealityKit, ARView is already a UIView subclass — add it as a subview and configure its cameraMode. The SwiftUI SceneView wrapper covers the common case with far less boilerplate.
Last reviewed: 2026-05-11 by the Soarias team.