Orbit camera around object

View on GitHub

Fix the camera to point at and rotate around a target object.

Image of orbit camera around object

Use case

The orbit geoelement camera controller provides control over the following camera behaviors:

  • Automatically track the target
  • Stay near the target by setting a minimum and maximum distance offset
  • Restrict where you can rotate around the target
  • Automatically rotate the camera when the target's heading and pitch changes
  • Disable user interactions for rotating the camera
  • Animate camera movement over a specified duration
  • Control the vertical positioning of the target on the screen
  • Set a target offset (e.g., to orbit around the tail of the plane) instead of defaulting to orbiting the center of the object

How to use the sample

The sample loads with the camera orbiting an airplane model. The camera is preset with a restricted camera heading, pitch, and a limited minimum and maximum camera distance set from the plane. The position of the plane on the screen is also set just below center.

Tap "Cockpit" to offset and fix the camera into the cockpit of the airplane. The camera will follow the pitch of the plane in this mode. In this view, adjusting the camera distance is disabled. Tap "Center" to exit the cockpit view and fix the camera controller on the center of the plane.

Use the "Camera Heading" slider to adjust the camera heading. Use the "Plane Pitch" slider to adjust the plane's pitch. When not in Cockpit view, the plane's pitch will change independently to that of the camera pitch.

Toggle on the "Allow Camera Distance Interaction" switch to allow zooming in and out by pinching. When the toggle is off, the user will be unable to adjust the camera distance.

How it works

  1. Instantiate an OrbitGeoElementCameraController with a GeoElement and camera distance as parameters.
  2. Set the camera controller to the scene view.
  3. Set the cameraHeadingOffset, cameraPitchOffset, and cameraDistance properties for the camera controller.
  4. Set the minimum and maximum angle of heading and pitch, and minimum and maximum distance for the camera.
  5. Set the distance from which the camera is offset from the plane using setTargetOffsets(x:y:z:duration:) or the properties.
  6. Set the targetVerticalScreenFactor property to determine where the plane appears in the scene.
  7. Animate the camera to the cockpit using moveCamera(distanceDelta:headingDelta:pitchDelta:duration:).
  8. Set cameraDistanceIsInteractive if the camera distance will adjust when zooming or panning using mouse or keyboard (default is true).
  9. Set autoPitchIsEnabled if the camera will follow the pitch of the plane (default is true).

Relevant API

  • OrbitGeoElementCameraController

Tags

3D, camera, object, orbit, rotate, scene

Sample Code

OrbitCameraAroundObjectView.swiftOrbitCameraAroundObjectView.swiftOrbitCameraAroundObjectView.Model.swift
Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// Copyright 2024 Esri
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import ArcGIS
import SwiftUI

struct OrbitCameraAroundObjectView: View {
    /// The view model for the sample.
    @StateObject private var model = Model()

    /// The camera view selection.
    @State private var selectedCameraView = CameraView.center

    /// A Boolean value indicating whether the settings sheet is presented.
    @State private var settingsSheetIsPresented = false

    /// A Boolean value indicating whether scene interaction is disabled.
    @State private var sceneIsDisabled = false

    /// The error shown in the error alert.
    @State private var error: Error?

    var body: some View {
        SceneView(
            scene: model.scene,
            cameraController: model.cameraController,
            graphicsOverlays: [model.graphicsOverlay]
        )
        .disabled(sceneIsDisabled)
        .toolbar {
            ToolbarItemGroup(placement: .bottomBar) {
                cameraViewPicker
                settingsButton
            }
        }
        .errorAlert(presentingError: $error)
    }

    /// The picker for selecting the camera view.
    private var cameraViewPicker: some View {
        Picker("Camera View", selection: $selectedCameraView) {
            Text("Center").tag(CameraView.center)
            Text("Cockpit").tag(CameraView.cockpit)
        }
        .pickerStyle(.segmented)
        .task(id: selectedCameraView) {
            // Move the camera to the new view selection.
            do {
                // Disable scene interaction while the camera is moving.
                sceneIsDisabled = true
                defer { sceneIsDisabled = false }

                switch selectedCameraView {
                case .center:
                    try await model.moveToPlaneView()
                case .cockpit:
                    try await model.moveToCockpit()
                }
            } catch {
                self.error = error
            }
        }
    }

    /// The button that brings up the settings sheet.
    @ViewBuilder private var settingsButton: some View {
        let button = Button("Settings") {
            settingsSheetIsPresented = true
        }
        let settingsContent = SettingsView(model: model)

        if #available(iOS 16, *) {
            button
                .popover(isPresented: $settingsSheetIsPresented, arrowEdge: .bottom) {
                    settingsContent
                        .presentationDetents([.fraction(0.5)])
#if targetEnvironment(macCatalyst)
                        .frame(minWidth: 300, minHeight: 270)
#else
                        .frame(minWidth: 320, minHeight: 390)
#endif
                }
        } else {
            button
                .sheet(isPresented: $settingsSheetIsPresented, detents: [.medium]) {
                    settingsContent
                }
        }
    }
}

private extension OrbitCameraAroundObjectView {
    /// The camera and plane settings for the sample.
    struct SettingsView: View {
        /// The view model for the sample.
        @ObservedObject var model: Model

        /// The action to dismiss the view.
        @Environment(\.dismiss) private var dismiss: DismissAction

        /// The heading offset of the camera controller.
        @State private var cameraHeading = Measurement<UnitAngle>(value: 0, unit: .degrees)

        /// The pitch of the plane in the scene.
        @State private var planePitch = Measurement<UnitAngle>(value: 0, unit: .degrees)

        /// A Boolean value indicating whether the camera distance is interactive.
        @State private var cameraDistanceIsInteractive = false

        var body: some View {
            NavigationView {
                List {
                    VStack {
                        Text("Camera Heading")
                            .badge(
                                Text(cameraHeading, format: .degrees)
                            )

                        Slider(value: $cameraHeading.value, in: -45...45)
                            .onChange(of: cameraHeading.value) { newValue in
                                model.cameraController.cameraHeadingOffset = newValue
                            }
                    }

                    VStack {
                        Text("Plane Pitch")
                            .badge(
                                Text(planePitch, format: .degrees)
                            )

                        Slider(value: $planePitch.value, in: -90...90)
                            .onChange(of: planePitch.value) { newValue in
                                model.planeGraphic.setAttributeValue(newValue, forKey: "PITCH")
                            }
                    }

                    Toggle("Allow Camera Distance Interaction", isOn: $cameraDistanceIsInteractive)
                        .toggleStyle(.switch)
                        .disabled(model.cameraController.autoPitchIsEnabled)
                        .onChange(of: cameraDistanceIsInteractive) { newValue in
                            model.cameraController.cameraDistanceIsInteractive = newValue
                        }
                }
                .navigationTitle("Settings")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .confirmationAction) {
                        Button("Done") {
                            dismiss()
                        }
                    }
                }
            }
            .navigationViewStyle(.stack)
            .onAppear {
                planePitch.value = model.planeGraphic.attributes["PITCH"] as! Double
                cameraHeading.value = model.cameraController.cameraHeadingOffset
                cameraDistanceIsInteractive = model.cameraController.cameraDistanceIsInteractive
            }
        }
    }

    /// An enumeration representing a camera controller view.
    enum CameraView: CaseIterable {
        /// The view with the plane centered.
        case center
        /// The view from the plane's cockpit.
        case cockpit
    }
}

private extension FormatStyle where Self == Measurement<UnitAngle>.FormatStyle {
    /// The format style for degrees.
    static var degrees: Self {
        .measurement(
            width: .narrow,
            usage: .asProvided,
            numberFormatStyle: .number.precision(.fractionLength(0))
        )
    }
}

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.