Update the orientation of a graphic using expressions based on its attributes.

Use case
Instead of reading the attribute and changing the rotation on the symbol for a single graphic (a manual CPU operation), you can bind the rotation to an expression that applies to the whole overlay (an automatic GPU operation). This usually results in a noticeable performance boost (smooth rotations).
How to use the sample
Adjust the heading and pitch sliders to rotate the cone.
How it works
- Create a new graphics overlay.
- Create a simple renderer and set its scene properties.
- Set the heading expression to
[HEADING]. - Set the pitch expression to
[PITCH]. - Apply the renderer to the graphics overlay.
- Create a graphic and add it to the overlay.
- To update the graphic’s rotation, update the
HEADINGorPITCHproperty in the graphic’s attributes.
Relevant API
- Graphic.Attributes
- GraphicsOverlay
- SceneProperties
- SceneProperties.headingExpression
- SceneProperties.pitchExpression
- SimpleRenderer
- SimpleRenderer.SceneProperties
Tags
3D, expression, graphics, heading, pitch, rotation, scene, symbology
Sample Code
/* 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 * * http://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. * */
package com.esri.arcgismaps.sample.applyscenepropertyexpressions
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.runtime.Composableimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.esri.arcgismaps.sample.applyscenepropertyexpressions.screens.ApplyScenePropertyExpressionsScreenimport com.esri.arcgismaps.sample.sampleslib.BuildConfig
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // authentication with an API key or named user is // required to access basemaps and other location services ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ACCESS_TOKEN)
enableEdgeToEdge() setContent { SampleAppTheme { ApplyScenePropertyExpressionsApp() } } }
@Composable private fun ApplyScenePropertyExpressionsApp() { Surface(color = MaterialTheme.colorScheme.background) { ApplyScenePropertyExpressionsScreen( sampleName = getString(R.string.apply_scene_property_expressions_app_name) ) } }}/* 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 * * http://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. * */
package com.esri.arcgismaps.sample.applyscenepropertyexpressions.components
import android.app.Applicationimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableDoubleStateOfimport androidx.compose.runtime.setValueimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.mapping.ArcGISSceneimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.SurfacePlacementimport com.arcgismaps.mapping.symbology.SimpleMarkerSceneSymbolimport com.arcgismaps.mapping.symbology.SimpleMarkerSceneSymbolStyleimport com.arcgismaps.mapping.symbology.SimpleRendererimport com.arcgismaps.mapping.view.Cameraimport com.arcgismaps.mapping.view.Graphicimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport kotlinx.coroutines.launch
class ApplyScenePropertyExpressionsViewModel(app: Application) : AndroidViewModel(app) { // The ArcGISScene shown in the SceneView val arcGISScene = ArcGISScene(BasemapStyle.ArcGISImageryStandard).apply { // Set initial viewpoint with a camera looking at the cone val point = Point(x = 83.9, y = 28.4, z = 1000.0, spatialReference = SpatialReference.wgs84()) initialViewpoint = Viewpoint( latitude = 0.0, longitude = 0.0, scale = 1.0, camera = Camera(lookAtPoint = point, distance = 1000.0, heading = 0.0, pitch = 65.0, roll = 0.0) ) }
// UI state for heading and pitch sliders var heading by mutableDoubleStateOf(180.0) private set var pitch by mutableDoubleStateOf(45.0) private set
// Create the cone symbol private val coneSymbol = SimpleMarkerSceneSymbol( style = SimpleMarkerSceneSymbolStyle.Cone, color = Color.red, height = 100.0, width = 100.0, depth = 100.0 )
// The cone graphic, with mutable heading and pitch attributes private val coneGraphic = Graphic( geometry = Point(x = 83.9, y = 28.42, z = 200.0, spatialReference = SpatialReference.wgs84()), attributes = mapOf( "HEADING" to heading, "PITCH" to pitch ), symbol = coneSymbol )
// GraphicsOverlay with heading and pitch expressions val graphicsOverlay: GraphicsOverlay = GraphicsOverlay().apply { sceneProperties.surfacePlacement = SurfacePlacement.Relative // Set up renderer with heading and pitch expressions renderer = SimpleRenderer().apply { sceneProperties.headingExpression = "[HEADING]" sceneProperties.pitchExpression = "[PITCH]" } graphics.add(coneGraphic) }
// Message dialog for error handling val messageDialogVM = MessageDialogViewModel()
init { // Load the scene and handle errors viewModelScope.launch { arcGISScene.load().onFailure { messageDialogVM.showMessageDialog(it) } } }
// Called when the user changes the heading slider fun updateHeading(newHeading: Double) { heading = newHeading coneGraphic.attributes["HEADING"] = heading }
// Called when the user changes the pitch slider fun updatePitch(newPitch: Double) { pitch = newPitch coneGraphic.attributes["PITCH"] = pitch }}/* 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 * * http://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. * */
package com.esri.arcgismaps.sample.applyscenepropertyexpressions.screens
import android.content.res.Configurationimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Spacerimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.sizeimport androidx.compose.foundation.layout.wrapContentHeightimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Settingsimport androidx.compose.material3.ExperimentalMaterial3Apiimport androidx.compose.material3.FloatingActionButtonimport androidx.compose.material3.Iconimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.ModalBottomSheetimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Sliderimport androidx.compose.material3.Textimport androidx.compose.material3.rememberModalBottomSheetStateimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.tooling.preview.Previewimport androidx.compose.ui.unit.dpimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.SceneViewimport com.esri.arcgismaps.sample.applyscenepropertyexpressions.components.ApplyScenePropertyExpressionsViewModelimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.SamplePreviewSurfaceimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar
/** * Main screen layout for the sample app */@OptIn(ExperimentalMaterial3Api::class)@Composablefun ApplyScenePropertyExpressionsScreen(sampleName: String) { val sceneViewModel: ApplyScenePropertyExpressionsViewModel = viewModel() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var isSheetOpen by remember { mutableStateOf(false) }
Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, content = { padding -> Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { SceneView( modifier = Modifier.fillMaxSize(), arcGISScene = sceneViewModel.arcGISScene, graphicsOverlays = listOf(sceneViewModel.graphicsOverlay), ) // Settings bottom sheet if (isSheetOpen) { ModalBottomSheet( onDismissRequest = { isSheetOpen = false }, sheetState = sheetState ) { ScenePropertyExpressionsSettings( heading = sceneViewModel.heading, pitch = sceneViewModel.pitch, onHeadingChanged = sceneViewModel::updateHeading, onPitchChanged = sceneViewModel::updatePitch, ) } } } // Error dialog sceneViewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } } }, floatingActionButton = { if (!isSheetOpen) { // Floating action button to open settings FloatingActionButton( modifier = Modifier.padding(16.dp), onClick = { isSheetOpen = true } ) { Icon(Icons.Filled.Settings, contentDescription = "Settings") Spacer(Modifier.size(8.dp)) } } } )}
@Composablefun ScenePropertyExpressionsSettings( heading: Double, pitch: Double, onHeadingChanged: (Double) -> Unit, onPitchChanged: (Double) -> Unit,) { Column( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .padding(24.dp), verticalArrangement = Arrangement.spacedBy(18.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text("Expression Settings", style = MaterialTheme.typography.titleLarge) // Heading slider Text("Heading: ${heading.toInt()}°", style = MaterialTheme.typography.labelLarge) Slider( value = heading.toFloat(), onValueChange = { onHeadingChanged(it.toDouble()) }, valueRange = 0f..360f, ) // Pitch slider Text("Pitch: ${pitch.toInt()}°", style = MaterialTheme.typography.labelLarge) Slider( value = pitch.toFloat(), onValueChange = { onPitchChanged(it.toDouble()) }, valueRange = 0f..180f, ) }}
@Preview(showBackground = true)@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)@Composablefun PreviewScenePropertyExpressionsSettings() { SamplePreviewSurface { ScenePropertyExpressionsSettings( heading = 180.0, pitch = 45.0, onHeadingChanged = {}, onPitchChanged = {}, ) }}