Skip to main content

Maps Perspective Change

Last updated: June 16, 2026 | 5 minutes read

This example demonstrates how to toggle the map view between 2D (a vertical look-down at the map) and 3D (a tilted, perspective view looking towards the horizon). A floating button switches between the two, and the transition is animated.

Displaying the map in 2D
Displaying the map in 3D

Map Display and UI

MainActivity overrides onCreate, which inflates the view binding, calls setContentView(binding.root) for the layout in res/layout/activity_main.xml, and enables edge-to-edge drawing so the map fills the screen. It then wires up the toggle button in setupUi() and registers the SDK listeners.

When the activity is destroyed, onDestroy clears the listeners, deinitializes the SDK with GemSdk.release(), and exits the process so all native resources are freed.

MainActivity.ktView on Github
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)

binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

// Keep status-bar icons light against the dark primary toolbar background.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = false

setupUi()
registerSdkListeners()
}

private fun setupUi() {
binding.button.setOnClickListener {
togglePerspective()
}
}

The layout hosts a full-screen com.magiclane.sdk.core.GemSurfaceView that renders the map, with a circular MaterialButton anchored to the bottom-right corner. The button starts showing the 3D icon, indicating that a tap switches the map to 3D.

activity_main.xmlView on Github
<com.magiclane.sdk.core.GemSurfaceView
android:id="@+id/surface_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<com.google.android.material.button.MaterialButton
android:id="@+id/button"
android:layout_width="70dp"
android:layout_height="70dp"
android:contentDescription="@string/switch_to_three_dimensional"
app:icon="@drawable/ic_perspective_3d"
app:iconTint="@color/on_primary"
app:layout_constraintBottom_toBottomOf="@id/surface_view"
app:layout_constraintEnd_toEndOf="@id/surface_view" />

Toggling the Perspective

The activity keeps the currently applied perspective in currentPerspective, initialized to EMapViewPerspective.TwoDimensional so the map opens in 2D. Each tap calls togglePerspective(), which:

  1. Picks the perspective to switch to (the opposite of the current one).
  2. Updates the button's icon and content description to reflect the action the next tap will perform.
  3. Applies the new perspective with setMapViewPerspective, animated via Animation(EAnimation.Linear, 300) - a linear transition over 300 ms.

The call to setMapViewPerspective accesses the map engine, so it must run on the map engine's thread. Wrapping it in SdkCall.execute schedules it on that thread; calling SDK methods directly from the UI thread would throw an exception.

MainActivity.ktView on Github
private fun togglePerspective() {
val mapView = binding.surfaceView.mapView ?: return

// Pick the perspective to switch to and reflect the opposite action on the button.
currentPerspective = if (currentPerspective == EMapViewPerspective.TwoDimensional) {
binding.button.setIconResource(R.drawable.ic_perspective_2d)
binding.button.contentDescription = getString(R.string.switch_to_two_dimensional)
EMapViewPerspective.ThreeDimensional
} else {
binding.button.setIconResource(R.drawable.ic_perspective_3d)
binding.button.contentDescription = getString(R.string.switch_to_three_dimensional)
EMapViewPerspective.TwoDimensional
}

SdkCall.execute {
// Animate the transition to the newly selected perspective.
mapView.preferences?.setMapViewPerspective(
currentPerspective,
Animation(EAnimation.Linear, 300),
)
}
}

Reacting to SDK Initialization and Map Events

registerSdkListeners() wires up the SDK callbacks. onSdkInitFailed shows an error dialog and closes the activity if the SDK cannot initialize, onDefaultMapViewCreated aligns the Magic Lane logo once the map is ready, onSurfaceChanged re-aligns it on resize (e.g. rotation), and SdkSettings.onApiTokenRejected warns the user when the API token is invalid. The UI work is marshalled onto the main thread by runOnAliveUi, which only runs while the activity is still alive.

MainActivity.ktView on Github
private fun registerSdkListeners() {
binding.surfaceView.onSdkInitFailed = { error ->
// This call is synchronized, so resolve the error message directly (no SdkCall wrapping is required).
val errorMessage = getString(R.string.sdk_initialization_failed, GemError.getMessage(error, this))
runOnAliveUi {
showErrorDialog(errorMessage) { finish() }
}
}

binding.surfaceView.onDefaultMapViewCreated = {
// Align the Magic Lane logo with the system window insets on first map creation.
updateFocusViewport()
}

// Re-align the logo whenever the surface is resized (e.g. on rotation).
binding.surfaceView.onSurfaceChanged = { _, _ ->
updateFocusViewport()
}

SdkSettings.onApiTokenRejected = {
runOnAliveUi {
showErrorDialog(getString(R.string.token_rejected_message))
}
}
}

Keeping the Logo Clear of the System Bars

Because the map draws edge-to-edge, it extends underneath the toolbar and the system bars. updateFocusViewport() sets the map view's focusViewport to the region left after subtracting the system window insets, so the Magic Lane logo stays visible. Map access must run on the SDK thread, so the work is wrapped in SdkCall.runSynced.

MainActivity.ktView on Github
private fun updateFocusViewport() {
SdkCall.runSynced {
val mapView: MapView = binding.surfaceView.mapView ?: return@runSynced
val viewport = mapView.viewport ?: return@runSynced
val insets = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(SYSTEM_INSET_TYPES)

val left = insets?.left ?: 0
val top = insets?.top ?: 0
val right = (viewport.width - (insets?.right ?: 0)).coerceAtLeast(left)
val bottom = (viewport.height - (insets?.bottom ?: 0)).coerceAtLeast(top)
mapView.preferences?.focusViewport = Rect(left, top, right, bottom)
}
}

Releasing the Listeners

clearSdkListeners() is called from onDestroy to detach every callback before the activity goes away. Resetting the listeners to empty lambdas guarantees that a late SDK callback can never touch a destroyed activity.

MainActivity.ktView on Github
private fun clearSdkListeners() {
SdkSettings.onApiTokenRejected = {}
binding.surfaceView.apply {
onSdkInitFailed = {}
onDefaultMapViewCreated = {}
onSurfaceChanged = { _, _ -> }
}
}