An OrbitGeoElementCameraController
follows a graphic while the graphic's position and rotation are animated.
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
- Create a
GraphicsOverlay
and add it to theSceneView
. - Create a
ModelSceneSymbol
object. - Create a
Graphic
object with the model scene symbol. - Add heading, pitch, and roll attributes to the graphic.
- Create a
SimpleRenderer
object and set its expression properties. - Add the graphic and the renderer to the graphics overlay.
- Create a
OrbitGeoElementCameraController
which is set to target the graphic. - Assign the camera controller to the
SceneView
. - 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:
- Model Marker Symbol Data
- GrandCanyon.csv mission data
- Hawaii.csv mission data
- Pyrenees.csv mission data
- Snowdon.csv mission data
Tags
animation, camera, heading, pitch, roll, rotation, visualize
Sample Code
// 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(spacing: 3) {
LabeledContent("Altitude", value: model.animation.currentFrame.altitude, format: .length)
LabeledContent("Heading", value: model.animation.currentFrame.heading, format: .angle)
LabeledContent("Pitch", value: model.animation.currentFrame.pitch, format: .angle)
LabeledContent("Roll", value: model.animation.currentFrame.roll, format: .angle)
}
.frame(width: 170, height: 100)
.padding([.leading, .trailing])
.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.
withAnimation(.default.speed(2)) {
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.toggle()
} label: {
Image(systemName: model.animation.isPlaying ? "pause.fill" : "play.fill")
}
Spacer()
SettingsView(label: "Camera") {
cameraSettings
}
}
}
.task {
await model.monitorCameraController()
}
}
/// The list containing the mission settings.
private var missionSettings: some View {
List {
Section("Mission") {
VStack {
LabeledContent("Progress", value: model.animation.progress, format: .rounded)
ProgressView(value: model.animation.progress)
}
.padding(.vertical)
Picker("Mission Selection", selection: $model.currentMission) {
ForEach(Mission.allCases, id: \.self) { mission in
Text(mission.label)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
Section("Speed") {
Picker("Animation Speed", selection: $model.animation.speed) {
ForEach(AnimationSpeed.allCases, id: \.self) { speed in
Text(String(describing: speed).capitalized)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
}
}
/// The list containing the camera controller settings.
private var cameraSettings: some View {
List {
Section {
ForEach(CameraProperty.allCases, id: \.self) { property in
VStack {
LabeledContent(property.label, value: model.cameraPropertyTexts[property] ?? "")
Slider(value: cameraPropertyBinding(for: property), in: property.range, step: 1)
.padding(.horizontal)
}
}
}
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
}
}
}
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)
}
}