Animate 3D graphic

View on GitHub

An OrbitGeoElementCameraController follows a graphic while the graphic's position and rotation are animated.

Image of animate 3D graphic

Use case

Visualize movement through a 3D landscape.

How to use the sample

Tap the buttons on the bottom toolbar to adjust the settings for the animation:

  • Mission: change the flight path, speed, and view progress
  • Play/Pause: toggle the animation
  • Camera: change the camera distance, heading, pitch, and other camera properties.

How it works

  1. Create a GraphicsOverlay and add it to the SceneView.
  2. Create a ModelSceneSymbol object.
  3. Create a Graphic object with the model scene symbol.
  4. Add heading, pitch, and roll attributes to the graphic.
  5. Create a SimpleRenderer object and set its expression properties.
  6. Add the graphic and the renderer to the graphics overlay.
  7. Create a OrbitGeoElementCameraController which is set to target the graphic.
  8. Assign the camera controller to the SceneView.
  9. Update the graphic's location, heading, pitch, and roll.

Relevant API

  • Camera
  • GlobeCameraController
  • Graphic
  • GraphicsOverlay
  • LayerSceneProperties
  • ModelSceneSymbol
  • OrbitGeoElementCameraController
  • Renderer
  • RendererSceneProperties
  • Scene
  • SceneView
  • SurfacePlacement

Offline data

This sample uses the following data which are all included and downloaded on-demand:

Tags

animation, camera, heading, pitch, roll, rotation, visualize

Sample Code

Animate3DGraphicView.swiftAnimate3DGraphicView.swiftAnimate3DGraphicView.Model.swiftAnimate3DGraphicView.SettingsView.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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// Copyright 2023 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 Animate3DGraphicView: View {
    /// The view model for the sample.
    @StateObject private var model = Model()

    /// A Boolean value that indicates whether the full map view is showing.
    @State private var isShowingFullMap = false

    var body: some View {
        ZStack {
            /// The scene view with the plane model graphic.
            SceneView(
                scene: model.scene,
                cameraController: model.cameraController,
                graphicsOverlays: [model.sceneGraphicsOverlay]
            )

            /// The stats of the current position of the plane.
            VStack {
                HStack {
                    Spacer()
                    VStack {
                        StatRow("Altitude", value: model.animation.currentFrame.altitude.formatted(.length))
                        StatRow("Heading", value: model.animation.currentFrame.heading.formatted(.angle))
                        StatRow("Pitch", value: model.animation.currentFrame.pitch.formatted(.angle))
                        StatRow("Roll", value: model.animation.currentFrame.roll.formatted(.angle))
                    }
                    .frame(width: 170, height: 100)
                    .background(.ultraThinMaterial)
                    .cornerRadius(10)
                    .shadow(radius: 3)
                }
                .padding()
                Spacer()
            }

            /// The map view that tracks the plane on a 2D map.
            VStack {
                Spacer()
                HStack {
                    MapView(map: model.map, viewpoint: model.viewpoint, graphicsOverlays: [model.mapGraphicsOverlay])
                        .interactionModes([])
                        .attributionBarHidden(true)
                        .onSingleTapGesture { _, _ in
                            // Show/hide full map on tap.
                            isShowingFullMap.toggle()
                        }
                        .frame(width: isShowingFullMap ? nil : 100, height: isShowingFullMap ? nil : 100)
                        .cornerRadius(10)
                        .shadow(radius: 3)
                    Spacer()
                }
                .padding()
                .padding(.bottom)
            }
        }
        .toolbar {
            ToolbarItemGroup(placement: .bottomBar) {
                SettingsView(label: "Mission") {
                    missionSettings
                }
                Spacer()

                /// The play/pause button for the animation.
                Button {
                    model.animation.isPlaying ? model.animation.stop() : model.startAnimation()
                } label: {
                    Image(systemName: model.animation.isPlaying ? "pause.fill" : "play.fill")
                }

                Spacer()
                SettingsView(label: "Camera") {
                    cameraSettings
                }
            }
        }
        .task {
            await model.monitorCameraController()
        }
        .onDisappear {
            model.animation.stop()
        }
    }

    /// The list containing the mission settings.
    private var missionSettings: some View {
        List {
            Section("Mission") {
                Picker("Mission Selection", selection: $model.currentMission) {
                    ForEach(Mission.allCases, id: \.self) { mission in
                        Text(mission.label)
                    }
                }
                .pickerStyle(.inline)
                .labelsHidden()
            }

            Section {
                VStack {
                    StatRow("Animation Speed", value: model.animation.speed.formatted())
                    Slider(value: $model.animation.speed, in: 1...200, step: 1)
                        .onChange(of: model.animation.speed) { _ in
                            if model.animation.isPlaying {
                                model.startAnimation()
                            }
                        }
                }
                VStack {
                    StatRow("Mission Progress", value: model.animation.progress.formatted(.rounded))
                    ProgressView(value: model.animation.progress)
                        .padding()
                }
            }
        }
    }

    /// The list containing the camera controller settings.
    private var cameraSettings: some View {
        List {
            Section {
                ForEach(CameraProperty.allCases, id: \.self) { property in
                    VStack {
                        StatRow(property.label, value: model.cameraPropertyTexts[property] ?? "")
                        Slider(value: cameraPropertyBinding(for: property), in: property.range, step: 1)
                    }
                }
            }

            Section {
                Toggle("Auto-Heading Enabled", isOn: $model.cameraController.autoHeadingIsEnabled)
                Toggle("Auto-Pitch Enabled", isOn: $model.cameraController.autoPitchIsEnabled)
                Toggle("Auto-Roll Enabled", isOn: $model.cameraController.autoRollIsEnabled)
            }
        }
    }
}

extension Animate3DGraphicView {
    /// Creates a binding to a camera controller property based on a given property.
    /// - Parameter property: The property associated with a corresponding camera controller property.
    /// - Returns: A binding to a camera controller property on the model.
    func cameraPropertyBinding(for property: CameraProperty) -> Binding<Double> {
        switch property {
        case .distance: return $model.cameraController.cameraDistance
        case .heading: return $model.cameraController.cameraHeadingOffset
        case .pitch: return $model.cameraController.cameraPitchOffset
        }
    }

    /// A view for displaying a statistic name and value in a row.
    struct StatRow: View {
        /// The name of the statistic.
        var label: String
        /// The formatted value of the statistic.
        var value: String

        init(_ label: String, value: String) {
            self.label = label
            self.value = value
        }

        var body: some View {
            HStack {
                Text(label)
                Spacer()
                Text(value)
            }
            .padding([.leading, .trailing])
        }
    }
}

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

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

private extension FormatStyle where Self == FloatingPointFormatStyle<Double>.Percent {
    /// The format style for rounding percents.
    static var rounded: Self {
        .percent.rounded(rule: .up, increment: 0.1)
    }
}

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