```html SwiftUI: How to Build a 3D Model Viewer (iOS 17+, 2026)

How to Build a 3D Model Viewer in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: SceneKit / RealityKit Updated: May 11, 2026
TL;DR

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

  1. Async asset loading. Task.detached(priority: .userInitiated) moves the blocking SCNScene(url:options:) call off the main thread. The .checkConsistency and .convertToYUp options ensure USDZ files with mixed axis conventions render upright.
  2. @Observable view model. iOS 17's @Observable macro replaces ObservableObject. Only properties actually read by the view trigger re-renders, so loading state, error, and scene changes each invalidate minimally.
  3. SceneView options. .allowsCameraControl wires the built-in pinch, rotate, and pan gesture recognisers. .autoenablesDefaultLighting adds an omnidirectional light when no lights are present in the scene graph, so freshly exported USDZ files look correct without manual setup.
  4. Wireframe toggle. applyWireframe(_:to:) walks the scene's node hierarchy with enumerateChildNodes and flips every material's fillMode between .lines and .fill — a handy debug tool during development.
  5. ContentUnavailableView fallback. Introduced in iOS 17, ContentUnavailableView gives 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

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.

```