Skip to main content

Custom GPS Arrow

Last updated: June 18, 2026 | 6 minutes read

This example demonstrates how to replace the default position tracker with a custom GPS arrow. A 3D model is bundled in the project's assets folder, loaded as a scene object, and applied to the map's default position tracker. When the destination is reached (or navigation ends with an error), the follow-position button is disabled and any terminal error is surfaced to the user.

The simulation is used here only to produce GPS fixes for rendering the custom GPS arrow. The simulation route and the navigation panels are not displayed, as they are out of scope for this example.

Custom GPS arrow rendered on the map

Map Display and Setup

MainActivity overrides onCreate, which inflates the view binding with DataBindingUtil.setContentView, and enables edge-to-edge drawing so the map fills the screen. It then registers the SDK listeners and checks for an internet connection (needed to load the base map data).

When the activity is destroyed, onDestroy clears the listeners, deinitializes the SDK with GemSdk.release(), and exits the process. exitProcess is required because the SDK holds native threads that do not stop on their own when the activity is destroyed.

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

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

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

registerSdkListeners()

if (!Util.isInternetConnected(this)) {
showDialog(getString(R.string.internet_required))
}
}

Registering the SDK Listeners

registerSdkListeners() wires up the map surface callbacks. When the default map view is created, the example aligns the Magic Lane logo and loads the custom arrow with loadCustomArrow(). When the worldwide road map is up to date, it clears that callback (so it never fires twice) and starts the simulation.

MainActivity.ktView on Github
private fun registerSdkListeners() {
binding.gemSurface.onSdkInitFailed = { error ->
val errorMessage = getString(R.string.sdk_init_failed, GemError.getMessage(error, this))
runOnUiThread { showDialog(errorMessage) { finish() } }
}

binding.gemSurface.onDefaultMapViewCreated = {
// Position the logo before loading scene objects so it never overlaps the toolbar.
updateFocusViewport()
loadCustomArrow()
}

// The callback is cleared before starting the simulation to prevent it from firing
// a second time if the road-map status is updated again during the session.
SdkSettings.onWorldwideRoadMapSupportStatus = { status ->
if (status == EOffboardListenerStatus.UpToDate) {
SdkSettings.onWorldwideRoadMapSupportStatus = {}
startSimulation()
}
}

SdkSettings.onApiTokenRejected = {
runOnUiThread { showDialog(getString(R.string.token_rejected_message)) }
}
}

Loading the Custom Arrow

loadCustomArrow() is the core of the example. It reads the bundled quad.glb GLTF model from the assets folder, fetches the map's default position tracker with MapSceneObject.getDefPositionTracker(), and replaces its geometry with the loaded model through MapSceneObject.customizeDefPositionTracker(objList). The tracker's scaleFactor can be adjusted in the range 0.0 – 5.0 to size the arrow.

MainActivity.ktView on Github
// Loads the GLTF model from assets and applies it as the position-tracker arrow.
private fun loadCustomArrow() {
val objList = getSceneObjs("quad.glb" to ESceneObjectFileFormat.Gltf)
if (objList.isEmpty()) return

val (obj, err) = MapSceneObject.getDefPositionTracker()
if (GemError.isError(err)) {
val message = GemError.getMessage(err, this)
if (!message.isEmpty()) {
runOnUiThread { showDialog(message) }
}

return
}

MapSceneObject.customizeDefPositionTracker(objList)
obj?.scaleFactor = 1.0 // valid range: 0.0 – 5.0
}

getSceneObjs() reads each asset file and wraps its bytes in a SceneObjectData, collecting them into a SceneObjectDataList. Each file is loaded independently so one failure does not abort the rest.

MainActivity.ktView on Github
private fun getSceneObjs(vararg filesData: Pair<String, ESceneObjectFileFormat>): SceneObjectDataList {
val list: SceneObjectDataList = arrayListOf()
for ((fileName, format) in filesData) {
try {
val bytes = assets.open(fileName).use { it.readBytes() }
if (bytes.isNotEmpty()) {
list.add(SceneObjectData(DataBuffer(bytes), format))
}
} catch (e: Exception) {
e.printStackTrace()
}
}
return list
}

Starting the Simulation

Once the road map is ready, startSimulation() builds a two-waypoint route from London to Paris and starts a simulated navigation with navigationService.startSimulation(). The navigationListener drives the GPS-button lifecycle: onNavigationStarted enables the follow-position button and calls followPosition() so the camera tracks the custom arrow, while onDestinationReached and onNavigationError both route to onNavigationEnded() to tear it down again.

MainActivity.ktView on Github
private val navigationListener: NavigationListener = NavigationListener.create(
onNavigationStarted = {
SdkCall.execute {
binding.gemSurface.mapView?.let { mapView ->
enableGPSButton()
mapView.followPosition()
}
}
// Signals to Espresso that the async navigation-start operation is complete.
EspressoIdlingResource.decrement()
},
onDestinationReached = { onNavigationEnded() },
onNavigationError = { error -> onNavigationEnded(error) },
)
MainActivity.ktView on Github
private fun startSimulation() = SdkCall.execute {
val waypoints = arrayListOf(
Landmark("London", 51.5073204, -0.1276475),
Landmark("Paris", 48.8566932, 2.3514616),
)

val errorCode = navigationService.startSimulation(waypoints, navigationListener, routingProgressListener)
if (errorCode != GemError.NoError) {
runOnUiThread {
showDialog(
getString(
R.string.start_simulation_error,
SdkCall.runSynced { GemError.getMessage(errorCode, this) },
),
)
}
}
}

Following the Position

enableGPSButton() keeps the camera locked to the moving arrow. The follow-cursor button is hidden while the map is following the position and shown again as soon as the user pans away; tapping it re-centers the camera with followPosition().

MainActivity.ktView on Github
private fun enableGPSButton() {
binding.gemSurface.mapView?.apply {
onExitFollowingPosition = { binding.followCursor.visibility = View.VISIBLE }
onEnterFollowingPosition = { binding.followCursor.visibility = View.GONE }
binding.followCursor.setOnClickListener { SdkCall.execute { followPosition() } }
}
}

Ending Navigation

When the route is finished, the listener's onDestinationReached/onNavigationError callbacks invoke onNavigationEnded(). It surfaces any terminal error in a dialog (ignoring the benign NoError and Cancel codes) and then disables the GPS follow button.

MainActivity.ktView on Github
private fun onNavigationEnded(errorCode: ErrorCode = GemError.NoError) {
runOnUiThread {
if ((errorCode != GemError.NoError) && (errorCode != GemError.Cancel)) {
val message = SdkCall.runSynced { GemError.getMessage(errorCode, this) } ?: ""
if (message.isNotEmpty()) {
showDialog(message)
}
}
disableGPSButton()
}
}

disableGPSButton() is the counterpart of enableGPSButton(): it detaches the follow-position callbacks, clears the button's click listener, and hides it so no stale interaction remains once navigation is over.

MainActivity.ktView on Github
private fun disableGPSButton() {
binding.gemSurface.mapView?.apply {
onExitFollowingPosition = null
onEnterFollowingPosition = null
binding.followCursor.setOnClickListener(null)
binding.followCursor.visibility = View.GONE
}
}