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
GraphicsOverlayand add it to theArcGISSceneViewController. - Create a
ModelSceneSymbolobject. - Create a
Graphicobject and set its geometry to aArcGISPoint. - Set the
ModelSceneSymbolobject to the graphic. - Add heading, pitch, and roll attributes to the graphic.
- Create a
SimpleRendererobject and set its expression properties. - Add graphic and a renderer to the graphics overlay.
- Create a
OrbitGeoElementCameraControllerwhich is set to target the graphic. - Assign the camera controller to the
ArcGISSceneViewController. - Update the graphic’s location, heading, pitch, and roll.
Relevant API
- ArcGISScene
- ArcGISSceneViewController
- Camera
- GlobeCameraController
- Graphic
- GraphicsOverlay
- ModelSceneSymbol
- OrbitGeoElementCameraController
- Renderer
- SceneProperties
- 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
animate_3d_graphic.dart
// Copyright 2025 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 'dart:convert';import 'package:arcgis_maps/arcgis_maps.dart';import 'package:arcgis_maps_sdk_flutter_samples/common/common.dart';import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';import 'package:go_router/go_router.dart';
class Animate3dGraphic extends StatefulWidget { const Animate3dGraphic({super.key});
@override State<Animate3dGraphic> createState() => _Animate3dGraphicState();}
class _Animate3dGraphicState extends State<Animate3dGraphic> with SingleTickerProviderStateMixin, SampleStateSupport { // Create a controller for the scene view. final _sceneViewController = ArcGISSceneView.createController();
// Create a controller for the inset map view. final _mapViewController = ArcGISMapView.createController();
// The graphic for the route in the inset map. final _routeGraphic = Graphic(symbol: SimpleLineSymbol(color: Colors.red));
// The graphic for the triangle representing the plane on the inset map. final _triangleGraphic = Graphic( symbol: SimpleMarkerSymbol( style: SimpleMarkerSymbolStyle.triangle, color: Colors.blue, size: 10, ), );
// A flag for when the scene view is ready and controls can be used. var _ready = false;
// The graphic for the plane. Graphic? _planeGraphic;
// Track mission modal status. StateSetter? _modalStateSetter;
// The camera controller that follows the plane. late OrbitGeoElementCameraController _cameraController;
// Whether the animation is currently playing. var _isPlaying = false;
// The current frame index in the animation. var _currentFrameIndex = 0;
// The list of animation frames. var _frames = <Frame>[];
// The ticker that drives the animation. late final Ticker _ticker;
// The interval between animation frames in milliseconds. var _frameIntervalMs = 0;
// The currently selected mission. var _currentMission = Mission.grandCanyon;
// The selected animation speed. var _animationSpeed = AnimationSpeed.medium;
// Camera controller settings. double _cameraDistance = 1000; double _cameraHeading = 0; double _cameraPitch = 0; var _autoHeading = true; var _autoPitch = false; var _autoRoll = false;
// Data for real-time telemetry tracking. final _telemetryData = TelemetryData();
// The current progress of the animation. double get _progress { if (_frames.isEmpty) return 0; return _currentFrameIndex.toDouble() / _frames.length.toDouble(); }
@override void initState() { super.initState(); // Create the animation ticker. _ticker = createTicker(_onTick); _updateFrameInterval(); }
@override void dispose() { // Cleanup the ticker. _ticker.dispose(); _telemetryData.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return Scaffold( body: SafeArea( top: false, left: false, right: false, child: Stack( children: [ Column( children: [ Expanded( child: Stack( children: [ // Add a scene view to the widget tree and set a controller. ArcGISSceneView( controllerProvider: () => _sceneViewController, onSceneViewReady: onSceneViewReady, ), // Add an inset map showing the route. Align( alignment: Alignment.bottomLeft, child: Padding( padding: const EdgeInsets.fromLTRB(10, 10, 10, 40), child: Container( width: 150, height: 100, decoration: BoxDecoration(border: Border.all()), child: ArcGISMapView( controllerProvider: () => _mapViewController, onMapViewReady: onMapViewReady, ), ), ), ), ], ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // Button to open mission settings. ElevatedButton( onPressed: _showMissionSettings, child: const Text('Mission'), ), // Play/stop button. IconButton( onPressed: _toggleAnimation, icon: Icon(_isPlaying ? Icons.stop : Icons.play_arrow), ), // Button to open camera settings. ElevatedButton( onPressed: _showCameraSettings, child: const Text('Camera'), ), ], ), ], ), // Show real-time telemetry data. Visibility( visible: _ready, child: SafeArea( child: Align( alignment: Alignment.topRight, child: Container( margin: const EdgeInsets.all(16), width: 170, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(10), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTelemetryRow('Altitude', _telemetryData.altitude), _buildTelemetryRow('Heading', _telemetryData.heading), _buildTelemetryRow('Pitch', _telemetryData.pitch), _buildTelemetryRow('Roll', _telemetryData.roll), ], ), ), ), ), ), // Display a loading indicator until the scene is ready. LoadingIndicator(visible: !_ready), ], ), ), ); }
// Called when the scene view is ready. Future<void> onSceneViewReady() async { // Create and configure the scene with elevation. final scene = _createScene(); // Assign the scene to the scene view controller. _sceneViewController.arcGISScene = scene;
// Load the 3D plane graphic from local sample data. _planeGraphic = await _loadPlaneGraphic();
// Add the plane graphic to the scene and set the initial viewpoint. await _addPlaneToScene(_planeGraphic!);
// Set up the orbit camera controller to follow the plane. _setupCameraController(_planeGraphic!);
// Load the default mission animation frames. await _loadMissionFrames(_currentMission);
// Enable the UI once everything is ready. setState(() => _ready = true); }
// Creates a scene with an imagery basemap and adds elevation data. ArcGISScene _createScene() { final scene = ArcGISScene.withBasemapStyle(BasemapStyle.arcGISImagery);
// Add world elevation source to the scene's surface. final elevationSource = ArcGISTiledElevationSource.withUri( Uri.parse( 'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer', ), ); scene.baseSurface.elevationSources.add(elevationSource);
return scene; }
// Create the inset map and graphics. void onMapViewReady() { // Create a map with the streets basemap. final map = ArcGISMap.withBasemapStyle(BasemapStyle.arcGISStreets); _mapViewController.arcGISMap = map;
// Turn off attribution and gestures for the inset map. _mapViewController.isAttributionTextVisible = false; _mapViewController.interactionOptions.enabled = false;
// Create a graphics overlay with the graphics. final graphicsOverlay = GraphicsOverlay(); graphicsOverlay.graphics.addAll([_routeGraphic, _triangleGraphic]); _mapViewController.graphicsOverlays.add(graphicsOverlay); }
// Loads the 3D plane model from local sample data and returns it as a Graphic. Future<Graphic> _loadPlaneGraphic() async { // Define the initial position of the plane in the scene. final planePosition = ArcGISPoint( x: -109.937516, y: 38.456714, z: 5000, spatialReference: SpatialReference.wgs84, );
final listPaths = GoRouterState.of(context).extra! as List<String>; final planeModelPath = listPaths.first;
// Define the plane symbol. final planeSymbol = ModelSceneSymbol.withUri( uri: Uri.parse(planeModelPath), scale: 20, )..anchorPosition = SceneSymbolAnchorPosition.center;
// Return the graphic that combines geometry and symbol. return Graphic(geometry: planePosition, symbol: planeSymbol); }
// Adds the plane graphic to a graphics overlay and sets the initial viewpoint. Future<void> _addPlaneToScene(Graphic planeGraphic) async { final graphicsOverlay = GraphicsOverlay() ..graphics.add(planeGraphic) ..sceneProperties.surfacePlacement = SurfacePlacement.absolute;
// Apply renderer expressions for heading, pitch, and roll. final renderer = SimpleRenderer() ..sceneProperties.headingExpression = '[HEADING]' ..sceneProperties.pitchExpression = '[PITCH]' ..sceneProperties.rollExpression = '[ROLL]'; graphicsOverlay.renderer = renderer;
_sceneViewController.graphicsOverlays.add(graphicsOverlay);
// Set the initial viewpoint centered on the plane's position. final planePosition = planeGraphic.geometry! as ArcGISPoint; _sceneViewController.setViewpoint( Viewpoint.withPointScaleCamera( center: planePosition, scale: 100000, camera: Camera.withLookAtPoint( lookAtPoint: planePosition, distance: _cameraDistance, heading: _cameraHeading, pitch: _cameraPitch, roll: 0, ), ), ); }
// Configures the orbit camera controller to follow the plane graphic. void _setupCameraController(Graphic planeGraphic) { _cameraController = OrbitGeoElementCameraController( targetGeoElement: planeGraphic, distance: _cameraDistance, ) ..cameraHeadingOffset = _cameraHeading ..cameraPitchOffset = _cameraPitch ..isAutoHeadingEnabled = _autoHeading ..isAutoPitchEnabled = _autoPitch ..isAutoRollEnabled = _autoRoll ..minCameraDistance = 500 ..maxCameraDistance = 8000;
_sceneViewController.cameraController = _cameraController; }
// Loads mission frames from a CSV file using a PortalItem. Future<void> _loadMissionFrames(Mission mission) async { final portal = Portal.arcGISOnline(); final item = PortalItem.withPortalAndItemId( portal: portal, itemId: mission.itemId, ); final data = await item.fetchData(); final csv = utf8.decode(data);
final lines = const LineSplitter().convert(csv); final frames = <Frame>[];
for (final line in lines) { final parts = line.split(','); if (parts.length < 6) continue;
try { final x = double.parse(parts[0]); final y = double.parse(parts[1]); final z = double.parse(parts[2]); final heading = double.parse(parts[3]); final pitch = double.parse(parts[4]); final roll = double.parse(parts[5]);
final position = ArcGISPoint( x: x, y: y, z: z, spatialReference: SpatialReference.wgs84, );
frames.add( Frame(position: position, heading: heading, pitch: pitch, roll: roll), ); } on Exception { continue; } }
if (_ticker.isActive) { _ticker.stop(); setState(() => _isPlaying = false); } setState(() { _frames = frames; _currentFrameIndex = 0; });
if (_frames.isNotEmpty) { // Create the route graphic for the inset map. final builder = PolylineBuilder( spatialReference: frames.first.position.spatialReference, ); for (final frame in frames) { builder.addPoint(frame.position); } _routeGraphic.geometry = builder.toGeometry();
// Update the frame to the first frame. _updateFrame(_frames.first); } }
// Updates the interface with the current frame data. void _updateFrame(Frame frame) { if (_planeGraphic == null) return;
// Update the plane graphic. _planeGraphic!.geometry = frame.position; _planeGraphic!.attributes['HEADING'] = frame.heading; _planeGraphic!.attributes['PITCH'] = frame.pitch; _planeGraphic!.attributes['ROLL'] = frame.roll;
// Update the telemetry overlay. _telemetryData.altitude.value = frame.position.z ?? 0; _telemetryData.heading.value = frame.heading; _telemetryData.pitch.value = frame.pitch; _telemetryData.roll.value = frame.roll;
// Update the triangle graphic on the inset map. _triangleGraphic.geometry = frame.position;
// Keep the inset map centered on the plane. _mapViewController.setViewpoint( Viewpoint.fromCenter( frame.position, scale: 100000, rotation: frame.heading, ), ); }
// Telemetry Row Widget. Widget _buildTelemetryRow(String label, ValueNotifier<double> valueNotifier) { return ValueListenableBuilder( valueListenable: valueNotifier, builder: (context, value, child) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(color: Colors.white)), Text( label == 'Altitude' ? '${value.toStringAsFixed(0)} m' : '${value.toStringAsFixed(0)}°', style: const TextStyle(color: Colors.white), ), ], ), ); }, ); }
// Called on each tick to advance the animation. void _onTick(Duration elapsed) { // Calculate which frame should be shown at this elapsed time. var nextFrame = elapsed.inMilliseconds ~/ _frameIntervalMs; if (nextFrame >= _frames.length) { nextFrame = 0; _ticker.stop(); setState(() => _isPlaying = false); } if (nextFrame == _currentFrameIndex) return;
// Advance to the specified frame. setState(() => _currentFrameIndex = nextFrame); _updateFrame(_frames[_currentFrameIndex]);
// Update modal sheet if it's open. _modalStateSetter?.call(() {}); }
// Updates the frame interval based on the current animation speed. void _updateFrameInterval() { final framesPerSecond = 60 * _animationSpeed.frameStep; _frameIntervalMs = (1000 / framesPerSecond).round(); }
// Toggles the animation play/stop state. void _toggleAnimation() { if (_planeGraphic == null || _frames.isEmpty) return;
if (_ticker.isActive) { _ticker.stop(); } else { _ticker.start(); } setState(() => _isPlaying = _ticker.isActive); }
// Shows the mission settings in a bottom sheet. void _showMissionSettings() { showModalBottomSheet<void>( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) { return Padding( padding: bottomSheetPadding(context), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Mission Settings', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16),
// Progress widgets have to update for every frame, so wrapped with a StatefulBuilder. StatefulBuilder( builder: (context, setModalState) { // Store the setter. _modalStateSetter = setModalState; return Column( children: [ // Progress Indicator Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Progress', style: TextStyle(fontSize: 18), ), Text( '${(_progress * 100).toStringAsFixed(1)}%', style: const TextStyle(fontSize: 18), ), ], ), ), LinearProgressIndicator(value: _progress), ], ); }, ),
const SizedBox(height: 24), // Dropdown to select mission. DropdownMenu( initialSelection: _currentMission, onSelected: (mission) { if (mission != null) { if (_ticker.isActive) { _ticker.stop(); setState(() => _isPlaying = false); } setState(() { _currentMission = mission; _currentFrameIndex = 0; }); // Update the modal to show progress reset to 0 _modalStateSetter?.call(() {});
_loadMissionFrames(mission); } }, dropdownMenuEntries: Mission.values.map((mission) { return DropdownMenuEntry( value: mission, label: mission.label, ); }).toList(), ), const SizedBox(height: 16), DropdownMenu( initialSelection: _animationSpeed, onSelected: (speed) { if (speed != null) { setState(() => _animationSpeed = speed); _updateFrameInterval(); } }, dropdownMenuEntries: AnimationSpeed.values.map((speed) { return DropdownMenuEntry( value: speed, label: 'Speed: ${speed.label}', ); }).toList(), ), ], ), ); }, ).whenComplete(() { _modalStateSetter = null; }); }
// Shows the camera settings in a bottom sheet. void _showCameraSettings() { showModalBottomSheet<void>( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) { return StatefulBuilder( builder: (context, setModalState) { return Padding( padding: bottomSheetPadding(context), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Camera Settings', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), _buildSlider( label: 'Distance', value: _cameraDistance, min: 500, max: 8000, onChanged: (value) { setModalState(() => _cameraDistance = value); _cameraController.cameraDistance = value; }, ), _buildSlider( label: 'Heading Offset', value: _cameraHeading, min: -180, max: 180, onChanged: (value) { setModalState(() => _cameraHeading = value); _cameraController.cameraHeadingOffset = value; }, ), _buildSlider( label: 'Pitch Offset', value: _cameraPitch, min: 0, max: 180, onChanged: (value) { setModalState(() => _cameraPitch = value); _cameraController.cameraPitchOffset = value; }, ), SwitchListTile( title: const Text('Auto Heading'), value: _autoHeading, onChanged: (value) { setModalState(() => _autoHeading = value); _cameraController.isAutoHeadingEnabled = value; }, ), SwitchListTile( title: const Text('Auto Pitch'), value: _autoPitch, onChanged: (value) { setModalState(() => _autoPitch = value); _cameraController.isAutoPitchEnabled = value; }, ), SwitchListTile( title: const Text('Auto Roll'), value: _autoRoll, onChanged: (value) { setModalState(() => _autoRoll = value); _cameraController.isAutoRollEnabled = value; }, ), ], ), ); }, ); }, ); }
// Builds a labeled slider widget. Widget _buildSlider({ required String label, required double value, required double min, required double max, required ValueChanged<double> onChanged, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('$label: ${value.toStringAsFixed(0)}'), Slider( value: value, min: min, max: max, divisions: (max - min).toInt(), label: value.toStringAsFixed(0), onChanged: onChanged, ), const SizedBox(height: 4), ], ); }}
// A class to hold telemetry data in ValueNotifiers.class TelemetryData { final altitude = ValueNotifier<double>(0); final heading = ValueNotifier<double>(0); final pitch = ValueNotifier<double>(0); final roll = ValueNotifier<double>(0);
void dispose() { altitude.dispose(); heading.dispose(); pitch.dispose(); roll.dispose(); }}
// An enum of the different mission selections available in this sample.enum Mission { grandCanyon('Grand Canyon', '290f0c571c394461a8b58b6775d0bd63'), hawaii('Hawaii', 'e87c154fb9c2487f999143df5b08e9b1'), pyrenees('Pyrenees', '5a9b60cee9ba41e79640a06bcdf8084d'), snowdon('Snowdon', '12509ffdc684437f8f2656b0129d2c13');
const Mission(this.label, this.itemId);
// A human-readable label of the mission name. final String label;
// The ArcGIS Online item ID for the mission CSV file. final String itemId;}
// An enum representing the speed of the animation.enum AnimationSpeed { slow(1), medium(2), fast(4);
const AnimationSpeed(this.frameStep);
// The number of frames to advance per tick. final int frameStep;
// A label for the speed. String get label => name[0].toUpperCase() + name.substring(1);}
// A struct containing the location data for a single frame in a 3D animation.class Frame { Frame({ required this.position, required this.heading, required this.pitch, required this.roll, });
final ArcGISPoint position; final double heading; final double pitch; final double roll;}