OTS Map SDK for Android

A map SDK built on MapLibre GL Native, talking to the GTEL Maps backend. Idiomatic Kotlin API, coroutine-based calls, results wrapped in Resource<T> — you never touch the render engine underneath.

minSdk 24 Kotlin + coroutine MapLibre 11.13.5 Artifact AAR Service 9 (v1.1)
Map + Search
Style & Layer
Marker + clustering
9 service

What the SDK does#

Map & CameraRender map, 12 style, camera move/rotate/tilt.
POI InteractionBasemap POI tap, place detail lookups, custom sheet.
Marker & ClusteringMarkers, custom icons, automatic clustering, tap events.
Geocoding & SearchAutocomplete, reverse, nearby, place detail.
Routing & NavigationDraw routes, alternative routes, turn-by-turn.
Road & OtherSnap-to-road, admin boundary, weather, geofencing.
Realtime locationLocation puck, follow mode, raw GPS stream.

New here? Follow this order: InstallationInitializationMap View.

Installation

Three steps to a running map: add the dependency, declare permissions, set the API key.

1. Add the dependency#

Drop the AAR in directly, or resolve it from your internal Maven repo.

build.gradle.kts (app)
dependencies {
    // Option A — local AAR (you must declare the dependencies below by hand)
    implementation(files("libs/maps-sdk-0.1.0.aar"))

    // MapLibre — exposed via `api`, required on the classpath
    implementation("org.maplibre.gl:android-sdk:11.13.5")

    // AndroidX core (must already be on the host app)
    implementation("androidx.core:core-ktx:1.10.1")
    implementation("androidx.appcompat:appcompat:1.7.0")

    // Networking + coroutines (used internally by the SDK)
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")

    // Option B — internal Maven: transitives resolve automatically, nothing to declare
    // implementation("com.gtelmaps:maps-sdk:0.1.0")
}

Why declare them by hand? An AAR pulled in through files(...) carries no transitive dependencies. Go with option B (Maven) and Gradle resolves everything from a single line.

2. Declare permissions in the Manifest#

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<!-- only needed for My Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

3. API key#

Never hardcode it — keep it in local.properties (already gitignored) and inject it through BuildConfig.

local.properties
GTELMAP_API_KEY=your_gtelmap_api_key
GTELMAP_WEATHER_APP_ID=your_openweather_app_id   # only needed for the Weather service

API key is issued by the GTEL Maps team — mandatory; without it no tile will load.

Initialization & config

Initialize the SDK exactly once in Application.onCreate.

Application.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (!MapSdk.isInitialized) {
            MapSdk.init(MapSdkConfig(
                apiKey = BuildConfig.GTELMAP_API_KEY,
                weatherAppId = BuildConfig.GTELMAP_WEATHER_APP_ID.ifBlank { null },
                enableHttpLogging = BuildConfig.DEBUG,
            ))
        }
    }
}

Call MapSdk.init(...) before touching any other API, otherwise the app crashes with “MapSdk is not initialized”.

MapSdkConfig reference#

FieldDefaultDescription
apiKeyrequiredGTEL Maps key, attached to every request as ?apikey=
environmentPRODSelects the host per environment (DEV/STAGING/PROD)
defaultStyleIdgtelmaps-streets-v1Default style used by defaultStyleUrl()
apiBaseUrlper envv1.1 host; override when needed
weatherAppIdnullOpenWeather appid for the Weather service
enableHttpLoggingfalseLog OkHttp request/response bodies (debug only)

Map View

OtsMapView is the map view. Create it, forward the lifecycle, then attach it to your layout.

Compose (recommended)#

Copy this helper once and reuse it across the app — it handles the whole lifecycle forwarding:

Kotlin · Compose
@Composable
fun rememberOtsMapView(): OtsMapView {
    val ctx = LocalContext.current
    val owner = LocalLifecycleOwner.current
    val map = remember { OtsMapView(ctx).apply { onCreate(null) } }
    DisposableEffect(owner) {
        val obs = LifecycleEventObserver { _, e -> when (e) {
            Lifecycle.Event.ON_START  -> map.onStart()
            Lifecycle.Event.ON_RESUME -> map.onResume()
            Lifecycle.Event.ON_PAUSE  -> map.onPause()
            Lifecycle.Event.ON_STOP   -> map.onStop()
            else -> Unit
        } }
        owner.lifecycle.addObserver(obs)
        onDispose { owner.lifecycle.removeObserver(obs); map.onDestroy() }
    }
    return map
}
Kotlin · Compose
@Composable
fun MapScreen() {
    val map = rememberOtsMapView()
    AndroidView(factory = { map }, modifier = Modifier.fillMaxSize()) {
        it.setStyle(MapStyle.STREETS) {
            it.moveCamera(LatLng(21.0278, 105.8342), zoom = 12.0)
        }
    }
}
Result: a map of Hanoi

Activity / Fragment (View system)#

OtsMapView has an AttributeSet constructor, so it inflates straight from layout XML:

res/layout/activity_map.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.gtelmaps.sdk.core.OtsMapView
        android:id="@+id/mapView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</FrameLayout>

Then grab the view and forward the lifecycle from the Activity:

Kotlin
class MapActivity : AppCompatActivity() {
    private lateinit var map: OtsMapView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_map)
        map = findViewById(R.id.mapView)
        map.onCreate(savedInstanceState)
        map.setStyle(MapStyle.STREETS) {
            map.moveCamera(LatLng(21.0278, 105.8342), zoom = 12.0)
        }
    }

    // forwarding the full lifecycle is mandatory
    override fun onStart()   { super.onStart();   map.onStart() }
    override fun onResume()  { super.onResume();  map.onResume() }
    override fun onPause()   { map.onPause();   super.onPause() }
    override fun onStop()    { map.onStop();    super.onStop() }
    override fun onDestroy() { map.onDestroy(); super.onDestroy() }
    override fun onLowMemory() { super.onLowMemory(); map.onLowMemory() }
}

Using AppCompatActivity requires androidx.appcompat (already listed under Installation). Creating it in code works too: OtsMapView(this).

Camera

Move, animate, and fit the camera to a bounding box or to every marker.

Kotlin
map.moveCamera(LatLng(10.7769, 106.7009), zoom = 14.0)   // instant jump

map.animateCamera(                                       // smooth animation, rotate + tilt
    CameraPosition(LatLng(10.7769, 106.7009), zoom = 15.0, bearing = 30.0, tilt = 45.0),
    durationMs = 800,
)

map.fitBounds(LatLngBounds(LatLng(10.76, 106.69), LatLng(10.79, 106.71)))
map.fitToMarkers(paddingPx = 80)                       // fit every marker

Style & Layer

Switch between the 12 styles, toggle layers, list styles from the server.

Switch style#

Kotlin
map.setStyle(MapStyle.DARK)   // STREETS, SATELLITE, DARK, NAVIGATION_DAY…

Toggle layers#

Kotlin
map.setLayerVisibility("traffic", visible = true)
map.setLayersVisibilityByIdContains("poi", visible = false)
map.currentStyleLayerIds()    // -> List<String> of layer ids

List styles from the server#

Kotlin
val maps = MapsClient.create()
when (val r = maps.listStyles()) {
    is Resource.Success -> r.data.forEach { println("${it.name} → ${it.id}") }
    is Resource.Error   -> println(r.message)
    else -> Unit
}
Panel “Style & Layer”

Full list of the 12 styles: see the Cheatsheet.

POI Interaction

Tap a venue on the basemap and turn it into a place with a name, address, category and phone number. The SDK gives you the tap and the lookups; the callout UI is yours to build.

Different from iOS. GtelMapView on iOS draws the callout for you and pushes the result through didLoadPOIDetail. The Android SDK draws nothing: it reports the tap through onMapTap, and you resolve the place and render your own bottom sheet. See the mapping table below.

The tap → place flow#

StepAPI
1. User taps the mapMapEventListener.onMapTap(point)
2. Coordinate → placeGeocodingClient.reverseGeocode(point, zoom = 18)
3. Keep venues onlyplace.layer == "poi" — filtered client-side; reverseGeocode takes no layers parameter
4. Full detailgeo.placeDetail(listOf(place.id!!))
5. Rendermap.addMarker(...) · map.fitBounds(place.viewport)

Resolve the tapped POI#

Kotlin
private val geo = GeocodingClient.create()

map.setEventListener(object : MapEventListener {
    override fun onMapTap(point: LatLng) {
        lifecycleScope.launch {
            val r = geo.reverseGeocode(point, size = 1, zoom = 18)
            val place = (r as? Resource.Success)?.data?.firstOrNull() ?: return@launch
            if (place.layer != "poi") return@launch   // a street address, not a venue
            showPoiSheet(place)
        }
    }
})

onMapTap fires only when the tap misses your own markers and clusters — a tap that lands on an SDK marker goes to onMarkerClick instead. Raise zoom to bias the reverse lookup toward venues rather than streets.

Fill in the detail sheet#

A reverse result carries only name, address and layer. Phone, website, category and viewport come from a second call keyed on the place id (its gid):

Kotlin
private suspend fun showPoiSheet(place: Place) {
    place.location?.let { map.addMarker(MarkerOptions(position = it, title = place.name)) }

    val id = place.id ?: return
    val detail = (geo.placeDetail(listOf(id)) as? Resource.Success)?.data?.firstOrNull() ?: place

    sheet.render(
        name     = detail.name,
        address  = detail.address,
        category = detail.category ?: detail.primaryType,
        phone    = detail.phone,
        website  = detail.website,
    )
    detail.viewport?.let { map.fitBounds(it) }   // the venue's suggested camera bounds
}
Field on PlaceWhere it comes from
layer"poi" · "address" · "region" … — how you tell a venue from a street
name · addressreverse, nearby and place-detail alike
category · primaryTypeplace-detail — localised label (Nhà hàng) and raw type (restaurant)
phone · website · emailplace-detail and nearby only; null on a reverse result
viewportplace-detail and nearby — pass straight to map.fitBounds(...)
distanceMetersmetres from the query point (reverse and nearby)
timeZoneplace-detail, e.g. Asia/Ho_Chi_Minh

Any field the API does not return stays null — check before displaying it.

POIs around a point#

To populate the map instead of waiting for a tap, ask for venues by category around a centre:

Kotlin
val r = geo.nearby(location = center, types = "restaurant,cafe", radius = 1500, size = 20)
if (r is Resource.Success) {
    map.setMarkers(r.data.mapNotNull { p ->
        p.location?.let { MarkerOptions(position = it, title = p.name, snippet = p.address) }
    })
    map.fitToMarkers()
}

These markers are SDK markers, so tapping one lands in onMarkerClick, not onMapTap. Use nearbyPaged(...) when the list needs a "load more". Full parameters on Geocoding & Search.

Hide the basemap POI labels#

When you render your own venue markers, the style's built-in POI labels underneath tend to collide with them:

Kotlin
map.setLayersVisibilityByIdContains("poi", visible = false)   // -> the layer ids it touched
map.currentStyleLayerIds()                                    // inspect what the style actually has

Layer ids differ per style, and they reset on every setStyle(...). Re-apply the visibility inside the onLoaded callback of setStyle.

iOS ⇄ Android mapping#

iOS delegateAndroid equivalent
didTouchAnnotationonMapTap (basemap POI) · onMarkerClick (your marker)
didRequestPlaceDetailthe moment you call geo.placeDetail(...) — show your own loading state
didLoadPOIDetail(PlaceDetailModel)Resource.Success returned by placeDetail, carrying a Place
didShowCallout · didUpdateCalloutSubtitle · didUpdateCalloutCategory · gtelMapViewDidRemoveCalloutno equivalent — the callout is host-app UI on Android
handlePOICalloutTap() · handlePOICalloutDirection()your own button handlers; wire the direction button to Navigation

Marker & Clustering

Add/remove markers, custom icons, automatic clustering and tap handling.

Add marker#

Kotlin
map.setMarkers(listOf(
    MarkerOptions(LatLng(21.0278, 105.8342), title = "Hồ Gươm"),
    MarkerOptions(LatLng(21.0368, 105.8342), title = "Hồ Tây"),
))
map.addMarker(MarkerOptions(LatLng(21.03, 105.85)))
map.clearMarkers()
val current = map.getMarkers()   // snapshot read-only

MarkerOptions

ParamTypeDescription
positionLatLngCoordinates, required
titleString?Display title
snippetString?Secondary description line
iconIdString?Icon id from the style sprite, or a bitmap you registered

Custom icon#

Register the bitmap once and reference it via iconId. Icons are re-registered automatically when the style reloads.

Kotlin
map.registerIcon("my-pin", myBitmap)
map.addMarker(MarkerOptions(LatLng(21.0, 105.8), iconId = "my-pin"))

Clustering#

Clustering is on by default: nearby markers collapse into a cluster (a circle with a count), and tapping a cluster zooms the camera in — as shown here.

Marker tap event#

Kotlin
map.setEventListener(object : MapEventListener {
    override fun onMarkerClick(marker: MarkerOptions) {
        showDetail(marker.title)
    }
})

For the other events see Map Events.

Markers + automatic clustering (2 · 3 · 4 · 6)

Route

Draw a route, show alternative routes, and run turn-by-turn navigation.

Draw a route#

Kotlin
val nav = NavigationClient.create()
val r = nav.route(
    waypoints = listOf(LatLng(21.0278, 105.8342), LatLng(21.0368, 105.8500)),
    profile = "car",   // "car" | "bike" | "foot"
)
if (r is Resource.Success) {
    map.showRoute(r.data)
    println("${r.data.distanceMeters} m, ~${r.data.durationSeconds} s")
}
map.clearRoute()

Alternative routes#

Kotlin
val r = nav.alternativeRoutes(origin, destination)
if (r is Resource.Success) map.showRouteAlternatives(r.data, selectedIndex = 0)

// tap to pick a different route
map.queryRouteAlternativeAt(point) { i -> i?.let { map.selectRouteAlternative(it) } }

Turn-by-turn navigation#

Draws the route as a “vanishing line” (bright ahead, dimmed behind) plus a smoothly moving vehicle avatar.

Kotlin
map.showNavRoute(route.geometry, zoom = 17.0, tilt = 50.0)
map.setVehicleAvatar(carBitmap, position = route.geometry.first(), bearingDegrees = 0.0)

// on every location tick — the SDK interpolates position/bearing/camera/vanishing point
map.updateNavProgress(position = cur, bearingDeg = brg, fractionTraveled = 0.35, durationMs = 1000)
map.clearNavRoute()

If the user pans or zooms mid-navigation the camera is released for free look, then re-centers on the vehicle after ~6 seconds.

Overlay — Administrative boundary

Render province/ward boundaries on the map as a GeoJSON overlay.

Admin boundary#

Kotlin
val r = AdminUnitClient.create().provinceBoundary("01")
(r as? Resource.Success)?.data?.boundaryGeoJson?.let { map.showAdminBoundary(it) }
map.clearAdminBoundary()

Get the codes to pass in from AdminUnitClient.provinces() / wards(provinceCode) — see Road & Other Services.

Map Events

Handle taps, long-presses, marker taps and camera idle. Every method has an empty default — override only what you need.

Kotlin
map.setEventListener(object : MapEventListener {
    override fun onMapTap(point: LatLng) { }
    override fun onMapLongPress(point: LatLng) { }
    override fun onMarkerClick(marker: MarkerOptions) { }
    override fun onCameraIdle(center: LatLng, zoom: Double) { }
})
CallbackWhen it fires
onMapTapTap on empty space (no marker/cluster hit)
onMapLongPressLong-press anywhere on the map
onMarkerClickTap on a marker
onCameraIdleCamera goes idle after pan/zoom (viewport changed)

My Location

Location puck + follow mode on the map, or a raw location stream.

Request the ACCESS_FINE_LOCATION runtime permission first. Without it enableMyLocation() is a no-op — call it again once the user grants.

Kotlin
map.enableMyLocation(follow = true)   // show the puck + follow with the camera
map.setFollowMode(false)              // stop following, keep the puck
map.disableMyLocation()

Raw location stream#

When you only need coordinate callbacks (e.g. an origin for routing) and no puck on the map:

Kotlin
val tracker = LocationTracker.create(context) { latLng -> /* new fix */ }
tracker.start()   // emits the last-known fix immediately, then live updates
// …
tracker.stop()    // always stop to release the listener

Geocoding & Search

Autocomplete, forward search, reverse, nearby and place detail. Create one with GeocodingClient.create().

Autocomplete#

Kotlin
val geo = GeocodingClient.create()
val r = geo.autocomplete(
    text = "cà phê",
    focus = LatLng(21.0278, 105.8342),   // bias results toward this point
    layers = listOf(PlaceLayer.POI),
    size = 8,
)
if (r is Resource.Success) r.data.forEach { println("${it.name} — ${it.address}") }

Debounce by ~250 ms. For paging use searchPaged(...) with pageToken.

Reverse & nearby#

Kotlin
// coordinates → address
val addr = (geo.reverseGeocode(LatLng(21.0278, 105.8342)) as? Resource.Success)
    ?.data?.firstOrNull()?.address

// nearby POIs (rich payload: phone, website, distanceMeters, viewport…)
geo.nearby(location = LatLng(21.0278, 105.8342), types = "restaurant,cafe", radius = 1500)

Navigation Service

NavigationClient — OSRM-compatible routing. Returns a RouteResult (geometry + distance + duration + steps).

Kotlin
val nav = NavigationClient.create()

// basic route (≥ 2 waypoints)
nav.route(waypoints, profile = "car", overview = "full", steps = true)

// alternative routes (exactly 2 waypoints)
nav.alternativeRoutes(origin, destination)

// raw OSRM JSON to feed your own turn-by-turn engine
nav.rawRoute(waypoints, profile = "car")

To render the result on the map see Route.

Road & Other Services

Same shape everywhere: Xxx.create() → call a suspend fun → get a Resource. In the sample app they all live under “Toolbox”.

Kotlin
// Snap-to-road — align a GPS trace to the road network (2–100 points)
RoadClient.create().snapToRoads(points, interpolate = true)

// Admin unit — provinces/wards (+ boundary)
AdminUnitClient.create().provinces()
AdminUnitClient.create().wards(provinceCode = "01")

// Weather (needs weatherAppId)
WeatherClient.create().current(LatLng(21.0278, 105.8342))

// Geofencing — is the point inside the area?
GeofencingClient.create().byRadius(center, radiusMeters = 2000, points)
//   also available: byPolygon(geoJson, points) · byAdmin(adminCode, points)
Toolbox = the service catalogue

Backend still in progress: door-to-door (405) and static-map (not published yet). Every other service is verified live.

Resource, error & lifecycle

Every network call returns Resource<T> — a single type to handle.

Kotlin
when (val r = geo.reverseGeocode(point)) {
    is Resource.Success -> use(r.data)
    is Resource.Error   -> showError(r.message, r.code)
    Resource.Loading    -> Unit
}

// or, more compactly
geo.reverseGeocode(point)
    .onSuccess { places -> /* … */ }
    .onError   { err -> Log.e("Map", err.message) }

Lifecycle — what you must forward#

OtsMapView wraps the real map view, so the host must forward the lifecycle — otherwise the map never renders or leaks memory:

onCreate → onStart → onResume → onPause → onStop → onDestroy

  • Compose: fully handled by rememberOtsMapView() (see Map View).
  • Activity/Fragment: call each one manually from the matching callback.

Cheatsheet

12 style (MapStyle)#

STREETSSTREETS_HCMBASICLIGHT
DARKDARK_VMSSATELLITESATELLITE_STREETS
NAVIGATION_DAYNAVIGATION_NIGHTONLY_TRAFFICONLY_TRAFFIC_DARK

Common parameters#

Where it is usedAccepted values
profile — routingcar · bike · foot
units — weathermetric (°C) · imperial (°F) · standard (K)
layers — geocodingADDRESS · POI · COUNTRY · REGION · LOCALADMIN
types — nearby POIrestaurant, cafe, hospital, bank, hotel, gas_station, … (40+)

Facade#

MapsClient · GeocodingClient · NavigationClient · RoadClient · AdminUnitClient · WeatherClient · GeofencingClient

Troubleshooting

SymptomCause & fix
Crash “MapSdk is not initialized”MapSdk.init(...) was never called. Put it in Application.onCreate.
Blank map, no tiles loadWrong or missing apiKey, or the INTERNET permission is missing.
My Location puck never appearsThe location runtime permission was not requested; call enableMyLocation() again after the user grants it.
Map stutters or redraws on rotationThe lifecycle is not fully forwarded. On Compose use rememberOtsMapView().
You want to see which API the SDK callsTurn on enableHttpLogging = true (debug builds only).
Screenshots taken straight from the sample app running on an emulator (Hanoi, real API key).

Your first map

A runnable example from zero: initialize the SDK, build an OtsMapView in Compose, point the camera at Hanoi. Copy the three files below and the map is up.

API MapSdk · OtsMapView UI Compose Time ~5 min

1. Application — init SDK#

MyApp.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (!MapSdk.isInitialized) {
            MapSdk.init(MapSdkConfig(apiKey = BuildConfig.GTELMAP_API_KEY))
        }
    }
}

Remember to declare android:name=".MyApp" on the <application> tag in your Manifest.

2. A reusable lifecycle-forwarding helper#

MapComposables.kt
@Composable
fun rememberOtsMapView(): OtsMapView {
    val ctx = LocalContext.current
    val owner = LocalLifecycleOwner.current
    val map = remember { OtsMapView(ctx).apply { onCreate(null) } }
    DisposableEffect(owner) {
        val obs = LifecycleEventObserver { _, e -> when (e) {
            Lifecycle.Event.ON_START  -> map.onStart()
            Lifecycle.Event.ON_RESUME -> map.onResume()
            Lifecycle.Event.ON_PAUSE  -> map.onPause()
            Lifecycle.Event.ON_STOP   -> map.onStop()
            else -> Unit
        } }
        owner.lifecycle.addObserver(obs)
        onDispose { owner.lifecycle.removeObserver(obs); map.onDestroy() }
    }
    return map
}
Result: a map of Hanoi

3. Map screen#

MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MapScreen() }
    }
}

@Composable
fun MapScreen() {
    val map = rememberOtsMapView()
    AndroidView(factory = { map }, modifier = Modifier.fillMaxSize()) {
        it.setStyle(MapStyle.STREETS) {
            // set the camera after the style loads — avoids a first-frame jump
            it.moveCamera(LatLng(21.0278, 105.8342), zoom = 12.0)
        }
    }
}

A missing INTERNET permission or a wrong apiKey gives you a blank map and no exception. See Installation.

Next steps#

Switch styleLet users pick one of the 12 styles at runtime.
Markers & clusteringRender a POI list on the map with automatic clustering.
My LocationLocation puck plus a camera that follows the user.

Switch style at runtime

A row of style chips — tapping switches the map instantly, and existing markers and routes re-attach themselves.

API MapStyle · setStyle Style 12

Style chip row#

StylePicker.kt
private val PICKS = listOf(
    MapStyle.STREETS to "Streets",
    MapStyle.LIGHT to "Light",
    MapStyle.DARK to "Dark",
    MapStyle.SATELLITE to "Satellite",
    MapStyle.NAVIGATION_DAY to "Navigation",
)

@Composable
fun StylePicker(map: OtsMapView) {
    var selected by remember { mutableStateOf(MapStyle.STREETS) }
    LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        items(PICKS) { (style, label) ->
            FilterChip(
                selected = style == selected,
                onClick = {
                    selected = style
                    map.setStyle(style)   // markers/routes re-attach once the new style has loaded
                },
                label = { Text(label) },
            )
        }
    }
}

Toggle layers inside a style#

Kotlin
// list the layer ids of the current style
val ids = map.currentStyleLayerIds()

// hide every POI label, enable the traffic layer
map.setLayersVisibilityByIdContains("poi", visible = false)
map.setLayerVisibility("traffic", visible = true)
Panel “Style & Layer”

List styles from the server#

If you would rather not hardcode the 12 styles, ask the Maps Service directly:

Kotlin · coroutine
lifecycleScope.launch {
    when (val r = MapsClient.create().listStyles()) {
        is Resource.Success -> r.data.forEach { println("${it.name} → ${it.id}") }
        is Resource.Error   -> toast(r.message)
        else -> Unit
    }
}

// load by raw style id (including styles not yet in the MapStyle enum)
map.setStyleUrl(MapsClient.create().styleJsonUrl("gtelmaps-dark-vms-v1"))

setStyle reloads the entire style.json. Never call it in a loop or animation — wait for the onLoaded callback before doing anything else.

Markers + clustering from a list

Render a POI list on the map, fit the bounds, handle tap events and let the SDK cluster automatically.

API setMarkers · fitToMarkers Clustering automatic

Render markers on the map#

Kotlin
data class Poi(val name: String, val lat: Double, val lng: Double)

fun renderPois(map: OtsMapView, pois: List<Poi>) {
    map.setMarkers(
        pois.map {
            MarkerOptions(
                position = LatLng(it.lat, it.lng),
                title = it.name,
                snippet = "Tap for details",
            )
        }
    )
    map.fitToMarkers(paddingPx = 80)   // fit the bounds around every marker
}

A different icon per POI type#

Kotlin
// register once — icons re-register on every style reload
map.registerIcon("pin-cafe", cafeBitmap)
map.registerIcon("pin-atm", atmBitmap)

map.setMarkers(pois.map {
    MarkerOptions(
        position = LatLng(it.lat, it.lng),
        title = it.name,
        iconId = if (it.isCafe) "pin-cafe" else "pin-atm",
    )
})

Marker tap → open details#

Kotlin
map.setEventListener(object : MapEventListener {
    override fun onMarkerClick(marker: MarkerOptions) {
        showBottomSheet(marker.title, marker.snippet)
    }
    override fun onMapTap(point: LatLng) = hideBottomSheet()
})
Clusters 2 · 3 · 4 · 6 — tap to zoom in

Clustering is on by default: nearby markers collapse into a cluster (a circle with a count) and tapping one zooms the camera in. Nothing else to configure.

For large lists call setMarkers(list) once instead of looping addMarker — every add is another GeoJSON source update.

Long-press → reverse geocode

Long-press anywhere on the map, drop a marker there, then reverse geocode it into an address.

API reverseGeocode Event onMapLongPress

The whole flow#

DropPin.kt
class DropPinController(
    private val map: OtsMapView,
    private val scope: CoroutineScope,
    private val onAddress: (LatLng, String) -> Unit,
) {
    private val geo = GeocodingClient.create()
    private var job: Job? = null

    fun attach() {
        map.setEventListener(object : MapEventListener {
            override fun onMapLongPress(point: LatLng) {
                map.setMarkers(listOf(MarkerOptions(point, title = "Resolving address…")))
                job?.cancel()
                job = scope.launch {
                    when (val r = geo.reverseGeocode(point)) {
                        is Resource.Success -> {
                            val place = r.data.firstOrNull()
                            val label = place?.address ?: place?.name ?: "Unknown address"
                            map.setMarkers(listOf(MarkerOptions(point, title = label)))
                            onAddress(point, label)
                        }
                        is Resource.Error -> onAddress(point, "Error: ${r.message}")
                        else -> Unit
                    }
                }
            }
        })
    }
}

Wire it into the screen#

Kotlin · Compose
val map = rememberOtsMapView()
val scope = rememberCoroutineScope()
var address by remember { mutableStateOf<String?>(null) }

LaunchedEffect(map) {
    DropPinController(map, scope) { _, label -> address = label }.attach()
}
address?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }

Want the address to track the screen centre as the user pans? Use onCameraIdle(center, zoom) instead of onMapLongPress — and debounce it to save requests.

Route A → B + alternatives

Call the Navigation Service, render the route on the map, show the alternatives and let the user tap to choose.

API NavigationClient Render showRoute Profile car · bike · foot

A single route#

Kotlin · coroutine
private val nav = NavigationClient.create()

suspend fun drawRoute(map: OtsMapView, from: LatLng, to: LatLng) {
    when (val r = nav.route(waypoints = listOf(from, to), profile = "car", steps = true)) {
        is Resource.Success -> {
            val route = r.data
            map.showRoute(route)
            map.fitBounds(route.geometry.bounds(), paddingPx = 72)
            println("%.1f km · %d min".format(route.distanceMeters / 1000, (route.durationSeconds / 60).toInt()))
            route.steps.forEach { println("${it.maneuverType} ${it.modifier.orEmpty()} → ${it.roadName}") }
        }
        is Resource.Error -> toast(r.message)
        else -> Unit
    }
}

No bounds() helper in the SDK? Compute it yourself: LatLngBounds(LatLng(minLat, minLng), LatLng(maxLat, maxLng)) from route.geometry.

Alternative routes + tap to select#

Kotlin
val r = nav.alternativeRoutes(origin = from, destination = to, profile = "car")
if (r is Resource.Success) {
    map.showRouteAlternatives(r.data, selectedIndex = 0)   // the selected route is bold, the others dimmed

    map.setEventListener(object : MapEventListener {
        override fun onMapTap(point: LatLng) {
            map.queryRouteAlternativeAt(point) { index ->
                index?.let {
                    map.selectRouteAlternative(it)
                    showSummary(r.data[it])
                }
            }
        }
    })
}

map.clearRouteAlternatives()   // clear when leaving the screen

Use the user’s real position as origin#

Kotlin
val tracker = LocationTracker.create(context) { here ->
    scope.launch { drawRoute(map, from = here, to = destination) }
}
tracker.start()   // remember tracker.stop() in onDestroy

alternativeRoutes accepts exactly 2 waypoints. For multi-leg trips use route(waypoints) with a list of ≥ 2 waypoints.

Turn-by-turn navigation

The “driving” mode: the route is bright ahead and dimmed behind, the vehicle avatar moves smoothly, and a tilted camera follows it.

API showNavRoute · updateNavProgress Camera tilt 50° Interpolation handled by the SDK

Start a navigation session#

NavSession.kt
class NavSession(
    private val map: OtsMapView,
    private val route: RouteResult,
    private val carBitmap: Bitmap,
) {
    private val total = route.distanceMeters

    fun start() {
        map.setStyle(MapStyle.NAVIGATION_DAY) {
            map.showNavRoute(route.geometry, zoom = 17.0, tilt = 50.0)
            map.setVehicleAvatar(
                bitmap = carBitmap,
                position = route.geometry.first(),
                bearingDegrees = 0.0,
            )
        }
    }

    // call this on every new GPS fix
    fun onFix(position: LatLng, bearingDeg: Double, travelledMeters: Double) {
        map.updateNavProgress(
            position = position,
            bearingDeg = bearingDeg,
            fractionTraveled = (travelledMeters / total).coerceIn(0.0, 1.0),
            durationMs = 1000,   // = your update cadence; the SDK interpolates smoothly across it
        )
    }

    fun stop() {
        map.clearNavRoute()
        map.clearVehicleAvatar()
    }
}

Wire it to the location stream#

Kotlin
val session = NavSession(map, route, carBitmap).also { it.start() }

val tracker = LocationTracker.create(
    context = this,
    minTimeMs = 1000,          // navigation needs a tighter cadence than the default
    minDistanceMeters = 2f,
) { fix ->
    session.onFix(fix, bearingDeg = lastBearing, travelledMeters = travelled)
}
tracker.start()

// when the session ends
tracker.stop(); session.stop()

If the user pans or zooms mid-navigation the camera is released for free look and re-centers on the vehicle after ~6 seconds — nothing for you to handle.

Need textual turn instructions? Read route.steps (maneuverType, modifier, roadName), or use nav.rawRoute(...) to feed your own turn-by-turn engine.

My Location + follow mode

Request the runtime permission properly, show the location puck, toggle follow mode.

API enableMyLocation · setFollowMode Permission FINE_LOCATION

Request permission, then enable#

MyLocationButton.kt
@Composable
fun MyLocationButton(map: OtsMapView) {
    var following by remember { mutableStateOf(false) }

    val permission = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) { map.enableMyLocation(follow = true); following = true }
    }

    FloatingActionButton(onClick = {
        if (hasLocationPermission()) {
            following = !following
            if (following) map.enableMyLocation(follow = true) else map.setFollowMode(false)
        } else {
            permission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
        }
    }) {
        Icon(painterResource(R.drawable.ic_my_location), contentDescription = "My location")
    }
}

private fun Context.hasLocationPermission() =
    ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
        PackageManager.PERMISSION_GRANTED

Calling enableMyLocation() without permission is a silent no-op — no crash, but no puck either. Always call it again once the user grants.

Cleanup#

Kotlin
map.setFollowMode(false)    // keep the puck, stop the camera follow
map.disableMyLocation()   // fully off — call this when leaving the map screen

Coordinates only, no puck#

Kotlin
val tracker = LocationTracker.create(context) { latLng ->
    viewModel.onLocation(latLng)
}
tracker.start()   // emits the last-known fix immediately, then live updates
// …
tracker.stop()    // always stop to release the listener

OTS Map SDK cho Android

SDK bản đồ được xây dựng trên MapLibre GL Native và giao tiếp với backend GTEL Maps. Toàn bộ API viết theo phong cách Kotlin thuần, gọi bằng coroutine và trả kết quả qua Resource<T> — lập trình viên không phải làm việc trực tiếp với render engine bên dưới.

minSdk 24 Kotlin + coroutine MapLibre 11.13.5 Artifact AAR Service 9 (v1.1)
Map + Search
Style & Layer
Marker + clustering
9 service

SDK cung cấp những gì#

Map & CameraHiển thị bản đồ, 12 style dựng sẵn, điều khiển camera: di chuyển, xoay và nghiêng.
Tương tác POIChạm POI có sẵn trên bản đồ, tra cứu chi tiết địa điểm, hiển thị bottom sheet.
Marker & ClusteringMarker, icon tùy biến, tự động gom cụm (clustering) và bắt sự kiện chạm.
Geocoding & SearchGợi ý khi nhập, địa mã hóa nghịch (reverse geocoding), tìm lân cận và tra cứu chi tiết địa điểm.
Routing & NavigationVẽ tuyến đường, tuyến thay thế và dẫn đường từng ngã rẽ (turn-by-turn navigation).
Đường & dịch vụ khácSnap-to-road, ranh giới hành chính, thời tiết và geofencing.
Realtime locationLocation puck, chế độ camera theo dõi vị trí người dùng (Follow Mode) và luồng tọa độ GPS thô.

Mới bắt đầu? Hãy đi theo thứ tự: Cài đặtKhởi tạoHiển thị bản đồ.

Cài đặt

Ba bước để chạy được: thêm dependency, khai báo permission và cấu hình API key.

1. Thêm dependency#

Tích hợp trực tiếp file AAR vào thư mục libs của ứng dụng, hoặc lấy qua Maven repository nội bộ.

build.gradle.kts (app)
dependencies {
    // Cách A — tích hợp trực tiếp file AAR vào thư mục libs của ứng dụng: phải khai báo thủ công các dependency bên dưới
    implementation(files("libs/maps-sdk-0.1.0.aar"))

    // MapLibre — SDK khai báo bằng cấu hình `api` nên bắt buộc có trên classpath
    implementation("org.maplibre.gl:android-sdk:11.13.5")

    // AndroidX core — ứng dụng tích hợp phải có sẵn
    implementation("androidx.core:core-ktx:1.10.1")
    implementation("androidx.appcompat:appcompat:1.7.0")

    // Networking và coroutine — SDK dùng nội bộ
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")

    // Cách B — Maven nội bộ: Gradle tự phân giải dependency, không cần khai báo thủ công
    // implementation("com.gtelmaps:maps-sdk:0.1.0")
}

Vì sao phải khai báo thủ công? AAR nạp bằng files(...) không mang theo transitive dependency. Nếu dùng cách B (Maven), Gradle tự phân giải toàn bộ và bạn chỉ cần một dòng khai báo.

2. Khai báo permission trong Manifest#

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<!-- chỉ cần khi dùng My Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

3. API key#

Không hardcode API key: đặt trong local.properties (đã được gitignore) rồi truyền vào qua BuildConfig.

local.properties
GTELMAP_API_KEY=your_gtelmap_api_key
GTELMAP_WEATHER_APP_ID=your_openweather_app_id   # chỉ cần khi dùng Weather service

API key do team GTEL Maps cấp và là bắt buộc; thiếu key thì bản đồ không tải được tile.

Khởi tạo & cấu hình

Khởi tạo SDK đúng một lần trong Application.onCreate.

Application.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (!MapSdk.isInitialized) {
            MapSdk.init(MapSdkConfig(
                apiKey = BuildConfig.GTELMAP_API_KEY,
                weatherAppId = BuildConfig.GTELMAP_WEATHER_APP_ID.ifBlank { null },
                enableHttpLogging = BuildConfig.DEBUG,
            ))
        }
    }
}

Phải gọi MapSdk.init(...) trước khi dùng bất kỳ API nào; nếu không, ứng dụng sẽ crash với thông báo “MapSdk is not initialized”.

Bảng tham số MapSdkConfig#

Tham sốMặc địnhMô tả
apiKeybắt buộcKey do GTEL Maps cấp, được gắn vào mọi request dưới dạng ?apikey=
environmentPRODChọn host tương ứng với môi trường (DEV/STAGING/PROD)
defaultStyleIdgtelmaps-streets-v1Style mặc định mà defaultStyleUrl() sử dụng
apiBaseUrltheo envHost của API v1.1; ghi đè khi cần
weatherAppIdnullKhoá appid của OpenWeather, dùng cho Weather service
enableHttpLoggingfalseGhi log request/response của OkHttp; chỉ bật ở bản debug

Hiển thị bản đồ

OtsMapView là view bản đồ. Bạn khởi tạo view, chuyển tiếp sự kiện vòng đời (Lifecycle Forwarding) cho nó, rồi gắn vào layout.

Compose (khuyến nghị)#

Sao chép helper này một lần rồi dùng lại trong toàn ứng dụng — nó đảm nhiệm trọn phần chuyển tiếp sự kiện vòng đời (Lifecycle Forwarding):

Kotlin · Compose
@Composable
fun rememberOtsMapView(): OtsMapView {
    val ctx = LocalContext.current
    val owner = LocalLifecycleOwner.current
    val map = remember { OtsMapView(ctx).apply { onCreate(null) } }
    DisposableEffect(owner) {
        val obs = LifecycleEventObserver { _, e -> when (e) {
            Lifecycle.Event.ON_START  -> map.onStart()
            Lifecycle.Event.ON_RESUME -> map.onResume()
            Lifecycle.Event.ON_PAUSE  -> map.onPause()
            Lifecycle.Event.ON_STOP   -> map.onStop()
            else -> Unit
        } }
        owner.lifecycle.addObserver(obs)
        onDispose { owner.lifecycle.removeObserver(obs); map.onDestroy() }
    }
    return map
}
Kotlin · Compose
@Composable
fun MapScreen() {
    val map = rememberOtsMapView()
    AndroidView(factory = { map }, modifier = Modifier.fillMaxSize()) {
        it.setStyle(MapStyle.STREETS) {
            it.moveCamera(LatLng(21.0278, 105.8342), zoom = 12.0)
        }
    }
}
Kết quả: bản đồ Hà Nội

Activity / Fragment (View system)#

OtsMapView có constructor nhận AttributeSet nên khai báo trực tiếp được trong layout XML:

res/layout/activity_map.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.gtelmaps.sdk.core.OtsMapView
        android:id="@+id/mapView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</FrameLayout>

Sau đó lấy view ra và chuyển tiếp sự kiện vòng đời (Lifecycle Forwarding) từ Activity:

Kotlin
class MapActivity : AppCompatActivity() {
    private lateinit var map: OtsMapView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_map)
        map = findViewById(R.id.mapView)
        map.onCreate(savedInstanceState)
        map.setStyle(MapStyle.STREETS) {
            map.moveCamera(LatLng(21.0278, 105.8342), zoom = 12.0)
        }
    }

    // bắt buộc chuyển tiếp đầy đủ lifecycle
    override fun onStart()   { super.onStart();   map.onStart() }
    override fun onResume()  { super.onResume();  map.onResume() }
    override fun onPause()   { map.onPause();   super.onPause() }
    override fun onStop()    { map.onStop();    super.onStop() }
    override fun onDestroy() { map.onDestroy(); super.onDestroy() }
    override fun onLowMemory() { super.onLowMemory(); map.onLowMemory() }
}

Ví dụ dùng AppCompatActivity nên cần androidx.appcompat (đã liệt kê ở phần Cài đặt). Khởi tạo bằng code cũng được: OtsMapView(this).

Camera

Di chuyển, bay mượt và căn chỉnh camera vừa vặn vùng bao tọa độ theo một vùng hoặc theo toàn bộ marker.

Kotlin
map.moveCamera(LatLng(10.7769, 106.7009), zoom = 14.0)   // nhảy tức thì, không animation

map.animateCamera(                                       // bay mượt, có xoay và nghiêng
    CameraPosition(LatLng(10.7769, 106.7009), zoom = 15.0, bearing = 30.0, tilt = 45.0),
    durationMs = 800,
)

map.fitBounds(LatLngBounds(LatLng(10.76, 106.69), LatLng(10.79, 106.71)))
map.fitToMarkers(paddingPx = 80)                       // căn chỉnh camera bao trọn toàn bộ marker

Style & lớp bản đồ

Chuyển đổi giữa 12 style, bật/tắt từng layer và lấy danh sách style từ server.

Đổi style#

Kotlin
map.setStyle(MapStyle.DARK)   // STREETS, SATELLITE, DARK, NAVIGATION_DAY…

Bật/tắt layer#

Kotlin
map.setLayerVisibility("traffic", visible = true)
map.setLayersVisibilityByIdContains("poi", visible = false)
map.currentStyleLayerIds()    // -> List<String> chứa id của các layer

Lấy danh sách style từ server#

Kotlin
val maps = MapsClient.create()
when (val r = maps.listStyles()) {
    is Resource.Success -> r.data.forEach { println("${it.name} → ${it.id}") }
    is Resource.Error   -> println(r.message)
    else -> Unit
}
Panel “Style & Layer”

Danh sách đầy đủ 12 style: xem Tra nhanh.

Tương tác POI

Chạm vào một địa điểm có sẵn trên bản đồ và chuyển nó thành một POI đầy đủ tên, địa chỉ, loại hình và số điện thoại. SDK cung cấp sự kiện chạm cùng các hàm tra cứu; phần giao diện callout do ứng dụng tự dựng.

Khác biệt so với iOS. Trên iOS, GtelMapView tự vẽ callout và trả kết quả qua didLoadPOIDetail. SDK Android không vẽ sẵn thành phần nào: SDK chỉ báo sự kiện chạm qua onMapTap, ứng dụng tự tra cứu địa điểm và hiển thị bottom sheet của mình. Xem bảng đối chiếu ở cuối trang.

Luồng xử lý: chạm → địa điểm#

BướcAPI
1. Người dùng chạm bản đồMapEventListener.onMapTap(point)
2. Tọa độ → địa điểmGeocodingClient.reverseGeocode(point, zoom = 18)
3. Chỉ giữ lại POIplace.layer == "poi" — lọc phía client; reverseGeocode không có tham số layers
4. Lấy thông tin chi tiếtgeo.placeDetail(listOf(place.id!!))
5. Hiển thịmap.addMarker(...) · map.fitBounds(place.viewport)

Tra cứu POI vừa chạm#

Kotlin
private val geo = GeocodingClient.create()

map.setEventListener(object : MapEventListener {
    override fun onMapTap(point: LatLng) {
        lifecycleScope.launch {
            val r = geo.reverseGeocode(point, size = 1, zoom = 18)
            val place = (r as? Resource.Success)?.data?.firstOrNull() ?: return@launch
            if (place.layer != "poi") return@launch   // địa chỉ đường, không phải POI
            showPoiSheet(place)
        }
    }
})

onMapTap chỉ kích hoạt khi cú chạm không trúng marker hoặc cụm marker của ứng dụng; nếu chạm trúng marker do SDK quản lý, sự kiện chuyển sang onMarkerClick. Tăng giá trị zoom để kết quả reverse thiên về địa điểm thay vì tuyến đường.

Hoàn thiện bottom sheet chi tiết#

Kết quả reverse chỉ có name, addresslayer. Số điện thoại, website, loại hình và viewport được lấy ở lần gọi thứ hai theo id của địa điểm (giá trị gid):

Kotlin
private suspend fun showPoiSheet(place: Place) {
    place.location?.let { map.addMarker(MarkerOptions(position = it, title = place.name)) }

    val id = place.id ?: return
    val detail = (geo.placeDetail(listOf(id)) as? Resource.Success)?.data?.firstOrNull() ?: place

    sheet.render(
        name     = detail.name,
        address  = detail.address,
        category = detail.category ?: detail.primaryType,
        phone    = detail.phone,
        website  = detail.website,
    )
    detail.viewport?.let { map.fitBounds(it) }   // khung nhìn khuyến nghị của địa điểm
}
Trường của PlaceNguồn dữ liệu
layer"poi" · "address" · "region" … — căn cứ để phân biệt POI với địa chỉ đường
name · addresscó ở cả reverse, nearby và place-detail
category · primaryTypeplace-detail — nhãn đã bản địa hóa (Nhà hàng) và mã loại gốc (restaurant)
phone · website · emailchỉ có ở place-detail và nearby; luôn null trong kết quả reverse
viewportplace-detail và nearby — truyền trực tiếp cho map.fitBounds(...)
distanceMeterskhoảng cách tính bằng mét so với điểm truy vấn (reverse và nearby)
timeZoneplace-detail, ví dụ Asia/Ho_Chi_Minh

Trường nào API không trả về sẽ giữ giá trị null — cần kiểm tra trước khi hiển thị.

Danh sách POI quanh một điểm#

Nếu muốn hiển thị sẵn POI thay vì chờ người dùng chạm, hãy truy vấn theo loại hình quanh một tâm:

Kotlin
val r = geo.nearby(location = center, types = "restaurant,cafe", radius = 1500, size = 20)
if (r is Resource.Success) {
    map.setMarkers(r.data.mapNotNull { p ->
        p.location?.let { MarkerOptions(position = it, title = p.name, snippet = p.address) }
    })
    map.fitToMarkers()
}

Đây là marker do SDK quản lý, nên khi chạm vào sẽ kích hoạt onMarkerClick chứ không phải onMapTap. Dùng nearbyPaged(...) khi danh sách cần chức năng tải thêm. Tham số đầy đủ xem tại Geocoding & tìm kiếm.

Ẩn nhãn POI của basemap#

Khi ứng dụng đã hiển thị marker POI riêng, các nhãn POI dựng sẵn trong style thường chồng lấn lên chúng:

Kotlin
map.setLayersVisibilityByIdContains("poi", visible = false)   // -> danh sách id layer đã tác động
map.currentStyleLayerIds()                                    // liệt kê layer thực có trong style

Id của layer khác nhau giữa các style và được đặt lại sau mỗi lần setStyle(...). Hãy áp dụng lại thiết lập hiển thị trong callback onLoaded của setStyle.

Đối chiếu iOS ⇄ Android#

Delegate trên iOSTương đương trên Android
didTouchAnnotationonMapTap (POI của basemap) · onMarkerClick (marker của ứng dụng)
didRequestPlaceDetailthời điểm ứng dụng gọi geo.placeDetail(...) — tự hiển thị trạng thái đang tải
didLoadPOIDetail(PlaceDetailModel)Resource.Success do placeDetail trả về, chứa đối tượng Place
didShowCallout · didUpdateCalloutSubtitle · didUpdateCalloutCategory · gtelMapViewDidRemoveCalloutkhông có tương đương — trên Android, callout thuộc phần giao diện của ứng dụng
handlePOICalloutTap() · handlePOICalloutDirection()ứng dụng tự xử lý sự kiện nút; nút chỉ đường nối sang Dẫn đường

Marker & Tự động gom cụm (Marker Clustering)

Thêm và xoá marker, dùng icon tùy biến, tự động gom cụm và xử lý sự kiện chạm.

Thêm marker#

Kotlin
map.setMarkers(listOf(
    MarkerOptions(LatLng(21.0278, 105.8342), title = "Hồ Gươm"),
    MarkerOptions(LatLng(21.0368, 105.8342), title = "Hồ Tây"),
))
map.addMarker(MarkerOptions(LatLng(21.03, 105.85)))
map.clearMarkers()
val current = map.getMarkers()   // bản sao chỉ đọc

MarkerOptions

Tham sốKiểuMô tả
positionLatLngTọa độ; bắt buộc
titleString?Tiêu đề hiển thị
snippetString?Dòng mô tả phụ
iconIdString?Id icon trong sprite của style, hoặc bitmap đã được đăng ký

Icon riêng#

Đăng ký bitmap một lần rồi tham chiếu qua iconId. Icon sẽ tự được đăng ký lại mỗi khi style được nạp lại.

Kotlin
map.registerIcon("my-pin", myBitmap)
map.addMarker(MarkerOptions(LatLng(21.0, 105.8), iconId = "my-pin"))

Tự động gom cụm điểm đánh dấu (Marker Clustering)#

Tính năng gom cụm (Marker Clustering) được bật sẵn: các marker ở gần nhau được tự động gom thành cụm, hiển thị dưới dạng vòng tròn kèm số lượng; chạm vào cụm thì camera tự phóng to — như ảnh bên.

Sự kiện chạm marker#

Kotlin
map.setEventListener(object : MapEventListener {
    override fun onMarkerClick(marker: MarkerOptions) {
        showDetail(marker.title)
    }
})

Các sự kiện khác: xem Sự kiện trên bản đồ.

Marker và cụm tự động (2 · 3 · 4 · 6)

Vẽ tuyến đường

Vẽ tuyến đường, hiển thị các tuyến thay thế và chạy chế độ dẫn đường từng ngã rẽ (turn-by-turn navigation).

Vẽ một tuyến#

Kotlin
val nav = NavigationClient.create()
val r = nav.route(
    waypoints = listOf(LatLng(21.0278, 105.8342), LatLng(21.0368, 105.8500)),
    profile = "car",   // "car" | "bike" | "foot"
)
if (r is Resource.Success) {
    map.showRoute(r.data)
    println("Dài ${r.data.distanceMeters} m, khoảng ${r.data.durationSeconds} s")
}
map.clearRoute()

Tuyến thay thế#

Kotlin
val r = nav.alternativeRoutes(origin, destination)
if (r is Resource.Success) map.showRouteAlternatives(r.data, selectedIndex = 0)

// chạm để chọn tuyến khác
map.queryRouteAlternativeAt(point) { i -> i?.let { map.selectRouteAlternative(it) } }

Dẫn đường từng ngã rẽ (Turn-by-turn Navigation)#

Tuyến được vẽ theo kiểu “vanishing line”: phần phía trước sáng rõ, phần đã đi qua mờ dần, kèm biểu tượng phương tiện di chuyển mượt trên tuyến.

Kotlin
map.showNavRoute(route.geometry, zoom = 17.0, tilt = 50.0)
map.setVehicleAvatar(carBitmap, position = route.geometry.first(), bearingDegrees = 0.0)

// mỗi lần có tọa độ mới — SDK tự nội suy vị trí, hướng, camera và điểm mờ
map.updateNavProgress(position = cur, bearingDeg = brg, fractionTraveled = 0.35, durationMs = 1000)
map.clearNavRoute()

Trong lúc dẫn đường, nếu người dùng kéo hoặc phóng to bản đồ, camera sẽ nhả chế độ bám theo để họ xem tự do, rồi tự căn lại về phương tiện sau khoảng 6 giây.

Lớp phủ — ranh giới hành chính

Vẽ ranh giới tỉnh/phường lên bản đồ dưới dạng lớp phủ GeoJSON.

Ranh giới hành chính#

Kotlin
val r = AdminUnitClient.create().provinceBoundary("01")
(r as? Resource.Success)?.data?.boundaryGeoJson?.let { map.showAdminBoundary(it) }
map.clearAdminBoundary()

Mã tỉnh/phường truyền vào đây lấy từ AdminUnitClient.provinces() hoặc wards(provinceCode) — xem Đường & dịch vụ khác.

Sự kiện trên bản đồ

Bắt các sự kiện chạm, nhấn giữ, chạm marker và camera dừng. Mọi phương thức đều có cài đặt mặc định rỗng nên bạn chỉ override những gì cần dùng.

Kotlin
map.setEventListener(object : MapEventListener {
    override fun onMapTap(point: LatLng) { }
    override fun onMapLongPress(point: LatLng) { }
    override fun onMarkerClick(marker: MarkerOptions) { }
    override fun onCameraIdle(center: LatLng, zoom: Double) { }
})
CallbackThời điểm được gọi
onMapTapChạm vào vùng trống, không trúng marker hay cụm
onMapLongPressNhấn giữ trên bản đồ
onMarkerClickChạm trúng một marker
onCameraIdleCamera dừng sau khi kéo hoặc phóng to, tức vùng hiển thị đã đổi

Vị trí của tôi

Hiển thị location puck cùng chế độ camera theo dõi vị trí người dùng (Follow Mode) trên bản đồ, hoặc chỉ lấy luồng tọa độ thô.

Phải xin runtime permission ACCESS_FINE_LOCATION trước. Khi chưa được cấp quyền, enableMyLocation() không làm gì cả — hãy gọi lại sau khi người dùng chấp thuận.

Kotlin
map.enableMyLocation(follow = true)   // hiển thị location puck và cho chế độ theo dõi vị trí người dùng (Follow Mode)
map.setFollowMode(false)              // ngừng bám theo nhưng vẫn giữ location puck
map.disableMyLocation()

Luồng tọa độ thô#

Khi chỉ cần callback tọa độ — ví dụ lấy điểm xuất phát cho chỉ đường — mà không cần location puck trên bản đồ:

Kotlin
val tracker = LocationTracker.create(context) { latLng -> /* có tọa độ mới */ }
tracker.start()   // phát ngay tọa độ gần nhất, sau đó cập nhật liên tục
// …
tracker.stop()    // luôn gọi stop để giải phóng listener

Geocoding & tìm kiếm

Gợi ý khi nhập, tìm kiếm chính xác, địa mã hóa nghịch (reverse geocoding), tìm lân cận và tra cứu chi tiết địa điểm. Khởi tạo bằng GeocodingClient.create().

Gợi ý khi gõ (autocomplete)#

Kotlin
val geo = GeocodingClient.create()
val r = geo.autocomplete(
    text = "cà phê",
    focus = LatLng(21.0278, 105.8342),   // ưu tiên kết quả quanh điểm này
    layers = listOf(PlaceLayer.POI),
    size = 8,
)
if (r is Resource.Success) r.data.forEach { println("${it.name} — ${it.address}") }

Nên debounce khoảng 250 ms. Khi cần phân trang, dùng searchPaged(...) kèm pageToken.

Địa mã hóa nghịch & Tìm kiếm lân cận#

Kotlin
// tọa độ → địa chỉ
val addr = (geo.reverseGeocode(LatLng(21.0278, 105.8342)) as? Resource.Success)
    ?.data?.firstOrNull()?.address

// POI lân cận — dữ liệu đầy đủ: phone, website, distanceMeters, viewport…
geo.nearby(location = LatLng(21.0278, 105.8342), types = "restaurant,cafe", radius = 1500)

Dịch vụ dẫn đường

NavigationClient — định tuyến theo chuẩn OSRM. Kết quả là RouteResult gồm geometry, khoảng cách, thời gian và danh sách steps.

Kotlin
val nav = NavigationClient.create()

// tuyến cơ bản, từ 2 waypoint trở lên
nav.route(waypoints, profile = "car", overview = "full", steps = true)

// tuyến thay thế, đúng 2 waypoint
nav.alternativeRoutes(origin, destination)

// JSON OSRM nguyên bản, dùng để nạp vào engine turn-by-turn riêng
nav.rawRoute(waypoints, profile = "car")

Cách vẽ kết quả lên bản đồ: xem Vẽ tuyến đường.

Đường & dịch vụ khác

Tất cả các dịch vụ đều tuân theo mô hình thiết kế chuẩn hóa: Xxx.create() → gọi hàm suspend → nhận Resource. Trong ứng dụng mẫu, tất cả nằm ở mục “Bộ công cụ”.

Kotlin
// Snap-to-road — khớp lộ trình GPS vào mạng lưới đường, 2–100 điểm
RoadClient.create().snapToRoads(points, interpolate = true)

// Đơn vị hành chính — tỉnh/phường, kèm ranh giới
AdminUnitClient.create().provinces()
AdminUnitClient.create().wards(provinceCode = "01")

// Thời tiết — cần cấu hình weatherAppId
WeatherClient.create().current(LatLng(21.0278, 105.8342))

// Geofencing — kiểm tra điểm có nằm trong vùng hay không
GeofencingClient.create().byRadius(center, radiusMeters = 2000, points)
//   ngoài ra còn: byPolygon(geoJson, points) · byAdmin(adminCode, points)
Bộ công cụ — danh mục service

Backend đang hoàn thiện: door-to-door (trả về 405) và static-map (chưa công bố). Các service còn lại đều đã được kiểm chứng trực tiếp trên môi trường thật.

Resource, lỗi & lifecycle

Mọi lời gọi mạng đều trả về Resource<T> — chỉ một kiểu duy nhất cần xử lý.

Kotlin
when (val r = geo.reverseGeocode(point)) {
    is Resource.Success -> use(r.data)
    is Resource.Error   -> showError(r.message, r.code)
    Resource.Loading    -> Unit
}

// hoặc viết gọn hơn
geo.reverseGeocode(point)
    .onSuccess { places -> /* … */ }
    .onError   { err -> Log.e("Map", err.message) }

Vòng đời (Lifecycle) — Các sự kiện cần chuyển tiếp#

OtsMapView bao bọc view bản đồ thật, nên ứng dụng chứa nó bắt buộc phải chuyển tiếp sự kiện vòng đời (Lifecycle Forwarding); nếu không, bản đồ sẽ không được vẽ hoặc gây rò rỉ bộ nhớ:

onCreate → onStart → onResume → onPause → onStop → onDestroy

  • Compose: đã được xử lý trọn vẹn trong rememberOtsMapView() (xem Map View).
  • Activity/Fragment: gọi thủ công trong từng callback tương ứng.

Tra nhanh

12 kiểu bản đồ (MapStyle)#

STREETSSTREETS_HCMBASICLIGHT
DARKDARK_VMSSATELLITESATELLITE_STREETS
NAVIGATION_DAYNAVIGATION_NIGHTONLY_TRAFFICONLY_TRAFFIC_DARK

Tham số hay dùng#

Dùng ở đâuGiá trị hợp lệ
profile — định tuyếncar · bike · foot
units — thời tiếtmetric (°C) · imperial (°F) · standard (K)
layers — geocodingADDRESS · POI · COUNTRY · REGION · LOCALADMIN
types — địa điểm lân cậnrestaurant, cafe, hospital, bank, hotel, gas_station, … (hơn 40 loại)

Facade#

MapsClient · GeocodingClient · NavigationClient · RoadClient · AdminUnitClient · WeatherClient · GeofencingClient

Xử lý sự cố

Triệu chứngNguyên nhân và cách khắc phục
Crash “MapSdk is not initialized”Chưa gọi MapSdk.init(...). Hãy đặt lời gọi này trong Application.onCreate.
Bản đồ trắng, không tải được tileSai hoặc thiếu apiKey, hoặc thiếu permission INTERNET.
Location puck không hiển thịChưa xin runtime permission vị trí. Hãy gọi lại enableMyLocation() sau khi người dùng chấp thuận.
Bản đồ giật hoặc vẽ lại khi xoay màn hìnhChưa chuyển tiếp đủ lifecycle. Với Compose, hãy dùng rememberOtsMapView().
Muốn biết SDK đang gọi API nàoBật enableHttpLogging = true; chỉ dùng cho bản debug.
Ảnh chụp trực tiếp từ ứng dụng mẫu chạy trên emulator (Hà Nội, API key thật).

Hiển thị bản đồ đầu tiên

Ví dụ chạy được từ đầu: khởi tạo SDK, dựng OtsMapView trong Compose và đưa camera về Hà Nội. Sao chép ba file bên dưới là bản đồ hiển thị được ngay.

API MapSdk · OtsMapView UI Compose Thời gian ~5 phút

1. Application — khởi tạo SDK#

MyApp.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (!MapSdk.isInitialized) {
            MapSdk.init(MapSdkConfig(apiKey = BuildConfig.GTELMAP_API_KEY))
        }
    }
}

Nhớ khai báo android:name=".MyApp" trong thẻ <application> của Manifest.

2. Helper chuyển tiếp sự kiện vòng đời (Lifecycle Forwarding), dùng lại được nhiều nơi#

MapComposables.kt
@Composable
fun rememberOtsMapView(): OtsMapView {
    val ctx = LocalContext.current
    val owner = LocalLifecycleOwner.current
    val map = remember { OtsMapView(ctx).apply { onCreate(null) } }
    DisposableEffect(owner) {
        val obs = LifecycleEventObserver { _, e -> when (e) {
            Lifecycle.Event.ON_START  -> map.onStart()
            Lifecycle.Event.ON_RESUME -> map.onResume()
            Lifecycle.Event.ON_PAUSE  -> map.onPause()
            Lifecycle.Event.ON_STOP   -> map.onStop()
            else -> Unit
        } }
        owner.lifecycle.addObserver(obs)
        onDispose { owner.lifecycle.removeObserver(obs); map.onDestroy() }
    }
    return map
}
Kết quả: bản đồ Hà Nội

3. Màn hình bản đồ#

MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MapScreen() }
    }
}

@Composable
fun MapScreen() {
    val map = rememberOtsMapView()
    AndroidView(factory = { map }, modifier = Modifier.fillMaxSize()) {
        it.setStyle(MapStyle.STREETS) {
            // đặt camera sau khi style nạp xong để tránh giật khung hình đầu
            it.moveCamera(LatLng(21.0278, 105.8342), zoom = 12.0)
        }
    }
}

Thiếu permission INTERNET hoặc sai apiKey thì bản đồ trắng và không hề báo lỗi. Xem Cài đặt.

Bước tiếp theo#

Đổi styleCho người dùng chọn 1 trong 12 style ngay lúc chạy.
Marker và clusteringVẽ danh sách POI lên bản đồ, tự động gom cụm.
My LocationLocation puck và chế độ theo dõi vị trí người dùng (Follow Mode) người dùng.

Đổi style khi đang chạy

Một dải chip chọn style: chạm vào là bản đồ đổi style ngay, các marker và tuyến đang hiển thị được gắn lại tự động.

API MapStyle · setStyle Style 12

Dải chip chọn style#

StylePicker.kt
private val PICKS = listOf(
    MapStyle.STREETS to "Đường phố",
    MapStyle.LIGHT to "Sáng",
    MapStyle.DARK to "Tối",
    MapStyle.SATELLITE to "Vệ tinh",
    MapStyle.NAVIGATION_DAY to "Điều hướng",
)

@Composable
fun StylePicker(map: OtsMapView) {
    var selected by remember { mutableStateOf(MapStyle.STREETS) }
    LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        items(PICKS) { (style, label) ->
            FilterChip(
                selected = style == selected,
                onClick = {
                    selected = style
                    map.setStyle(style)   // marker và tuyến được gắn lại sau khi style nạp xong
                },
                label = { Text(label) },
            )
        }
    }
}

Bật/tắt layer trong style#

Kotlin
// liệt kê id các layer của style hiện tại
val ids = map.currentStyleLayerIds()

// ẩn toàn bộ nhãn POI và bật layer giao thông
map.setLayersVisibilityByIdContains("poi", visible = false)
map.setLayerVisibility("traffic", visible = true)
Panel “Style & Layer”

Lấy danh sách style từ server#

Nếu không muốn hardcode 12 style, hãy hỏi trực tiếp Maps Service:

Kotlin · coroutine
lifecycleScope.launch {
    when (val r = MapsClient.create().listStyles()) {
        is Resource.Success -> r.data.forEach { println("${it.name} → ${it.id}") }
        is Resource.Error   -> toast(r.message)
        else -> Unit
    }
}

// nạp theo style id nguyên bản, kể cả style chưa có trong enum MapStyle
map.setStyleUrl(MapsClient.create().styleJsonUrl("gtelmaps-dark-vms-v1"))

setStyle sẽ nạp lại toàn bộ style.json, vì vậy đừng gọi trong vòng lặp hay animation. Hãy chờ callback onLoaded rồi mới thao tác tiếp.

Marker và Tự động gom cụm điểm đánh dấu (Marker Clustering) từ danh sách

Vẽ một danh sách POI lên bản đồ, căn chỉnh camera vừa vặn vùng bao tọa độ vừa đủ, xử lý sự kiện chạm và để SDK tự gom cụm.

API setMarkers · fitToMarkers Clustering tự động

Vẽ marker lên bản đồ#

Kotlin
data class Poi(val name: String, val lat: Double, val lng: Double)

fun renderPois(map: OtsMapView, pois: List<Poi>) {
    map.setMarkers(
        pois.map {
            MarkerOptions(
                position = LatLng(it.lat, it.lng),
                title = it.name,
                snippet = "Chạm để xem chi tiết",
            )
        }
    )
    map.fitToMarkers(paddingPx = 80)   // đóng khung vừa đủ toàn bộ marker
}

Icon riêng theo loại POI#

Kotlin
// đăng ký một lần; icon tự đăng ký lại mỗi khi style được nạp lại
map.registerIcon("pin-cafe", cafeBitmap)
map.registerIcon("pin-atm", atmBitmap)

map.setMarkers(pois.map {
    MarkerOptions(
        position = LatLng(it.lat, it.lng),
        title = it.name,
        iconId = if (it.isCafe) "pin-cafe" else "pin-atm",
    )
})

Chạm marker để mở chi tiết#

Kotlin
map.setEventListener(object : MapEventListener {
    override fun onMarkerClick(marker: MarkerOptions) {
        showBottomSheet(marker.title, marker.snippet)
    }
    override fun onMapTap(point: LatLng) = hideBottomSheet()
})
Cụm 2 · 3 · 4 · 6 — chạm để phóng to

Clustering được bật sẵn: các marker ở gần nhau được gom thành cụm, hiển thị dưới dạng vòng tròn kèm số lượng; chạm vào cụm thì camera tự phóng to. Không cần cấu hình gì thêm.

Với danh sách lớn, hãy gọi setMarkers(list) một lần thay vì lặp addMarker: mỗi lần thêm là một lượt cập nhật GeoJSON source.

Gợi ý tìm kiếm tự động với kỹ thuật khử rung (Debounce)

Gợi ý hiện ngay khi người dùng nhập: kỹ thuật khử rung (debounce 250ms), ưu tiên kết quả quanh vị trí camera, chọn xong thì camera bay tới địa điểm đó.

API GeocodingClient.autocomplete Debounce 250ms

ViewModel — dòng dữ liệu gợi ý#

SearchViewModel.kt
class SearchViewModel : ViewModel() {

    private val geo = GeocodingClient.create()
    private val query = MutableStateFlow("")
    var focus: LatLng? = null          // tâm camera hiện tại

    val suggestions: StateFlow<List<Place>> = query
        .debounce(250)
        .map { it.trim() }
        .distinctUntilChanged()
        .flatMapLatest { text ->
            if (text.length < 2) flowOf(emptyList())
            else flow {
                val r = geo.autocomplete(
                    text = text,
                    focus = focus,                    // ưu tiên kết quả ở gần vị trí này
                    layers = listOf(PlaceLayer.POI, PlaceLayer.ADDRESS),
                    size = 8,
                )
                emit(if (r is Resource.Success) r.data else emptyList())
            }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onQueryChange(text: String) { query.value = text }
}

Giao diện và xử lý khi chọn kết quả#

SearchBar.kt
@Composable
fun SearchBar(vm: SearchViewModel, map: OtsMapView) {
    var text by remember { mutableStateOf("") }
    val items by vm.suggestions.collectAsStateWithLifecycle()

    Column {
        OutlinedTextField(
            value = text,
            onValueChange = { text = it; vm.onQueryChange(it) },
            placeholder = { Text("Tìm địa điểm…") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth(),
        )
        LazyColumn {
            items(items) { place ->
                ListItem(
                    headlineContent = { Text(place.name) },
                    supportingContent = { place.address?.let { Text(it) } },
                    modifier = Modifier.clickable {
                        place.location?.let { map.moveCamera(it, zoom = 16.0) }
                        map.setMarkers(listOf(MarkerOptions(place.location!!, title = place.name)))
                        text = place.name
                    },
                )
            }
        }
    }
}

flatMapLatest tự huỷ request cũ ngay khi người dùng nhập tiếp, nhờ đó tránh được tình huống phản hồi cũ về sau và đè lên kết quả mới.

Các biến thể thường dùng#

Mục đíchHàm cần gọi
Tìm kiếm chính xác khi nhấn Entergeo.search(text, focus, size)
Cuộn vô hạn, tải theo tranggeo.searchPaged(...) + pageToken
POI quanh vị trí hiện tạigeo.nearby(location, types = "cafe", radius = 1500)
Chi tiết địa điểm: phone, website…geo.placeDetail(listOf(place.id!!))
Chỉ lấy cấp localadmin: phường, quậnlayers = listOf(PlaceLayer.LOCALADMIN)

Nhấn giữ để lấy địa chỉ

Nhấn giữ một điểm bất kỳ trên bản đồ, cắm marker tại đó rồi dùng địa mã hóa nghịch (địa mã hóa nghịch (reverse geocoding)) để lấy địa chỉ chi tiết.

API reverseGeocode Sự kiện onMapLongPress

Toàn bộ luồng xử lý#

DropPin.kt
class DropPinController(
    private val map: OtsMapView,
    private val scope: CoroutineScope,
    private val onAddress: (LatLng, String) -> Unit,
) {
    private val geo = GeocodingClient.create()
    private var job: Job? = null

    fun attach() {
        map.setEventListener(object : MapEventListener {
            override fun onMapLongPress(point: LatLng) {
                map.setMarkers(listOf(MarkerOptions(point, title = "Đang tra địa chỉ…")))
                job?.cancel()
                job = scope.launch {
                    when (val r = geo.reverseGeocode(point)) {
                        is Resource.Success -> {
                            val place = r.data.firstOrNull()
                            val label = place?.address ?: place?.name ?: "Không rõ địa chỉ"
                            map.setMarkers(listOf(MarkerOptions(point, title = label)))
                            onAddress(point, label)
                        }
                        is Resource.Error -> onAddress(point, "Lỗi: ${r.message}")
                        else -> Unit
                    }
                }
            }
        })
    }
}

Gắn vào màn hình#

Kotlin · Compose
val map = rememberOtsMapView()
val scope = rememberCoroutineScope()
var address by remember { mutableStateOf<String?>(null) }

LaunchedEffect(map) {
    DropPinController(map, scope) { _, label -> address = label }.attach()
}
address?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }

Muốn cập nhật địa chỉ theo tâm màn hình mỗi khi người dùng kéo bản đồ? Hãy dùng onCameraIdle(center, zoom) thay cho onMapLongPress, và nhớ debounce để đỡ tốn request.

Tuyến A → B và các tuyến thay thế

Gọi dịch vụ dẫn đường, vẽ tuyến lên bản đồ, hiển thị các tuyến thay thế và cho người dùng chạm để chọn.

API NavigationClient Vẽ bằng showRoute Profile car · bike · foot

Một tuyến đường#

Kotlin · coroutine
private val nav = NavigationClient.create()

suspend fun drawRoute(map: OtsMapView, from: LatLng, to: LatLng) {
    when (val r = nav.route(waypoints = listOf(from, to), profile = "car", steps = true)) {
        is Resource.Success -> {
            val route = r.data
            map.showRoute(route)
            map.fitBounds(route.geometry.bounds(), paddingPx = 72)
            println("%.1f km · %d phút".format(route.distanceMeters / 1000, (route.durationSeconds / 60).toInt()))
            route.steps.forEach { println("${it.maneuverType} ${it.modifier.orEmpty()} → ${it.roadName}") }
        }
        is Resource.Error -> toast(r.message)
        else -> Unit
    }
}

SDK chưa có sẵn bounds(), bạn tự tính từ route.geometry: LatLngBounds(LatLng(minLat, minLng), LatLng(maxLat, maxLng)).

Tuyến thay thế và thao tác chạm để chọn#

Kotlin
val r = nav.alternativeRoutes(origin = from, destination = to, profile = "car")
if (r is Resource.Success) {
    map.showRouteAlternatives(r.data, selectedIndex = 0)   // tuyến đang chọn vẽ đậm, các tuyến còn lại vẽ mờ

    map.setEventListener(object : MapEventListener {
        override fun onMapTap(point: LatLng) {
            map.queryRouteAlternativeAt(point) { index ->
                index?.let {
                    map.selectRouteAlternative(it)
                    showSummary(r.data[it])
                }
            }
        }
    })
}

map.clearRouteAlternatives()   // dọn dẹp khi rời màn hình

Lấy điểm xuất phát từ vị trí thật của người dùng#

Kotlin
val tracker = LocationTracker.create(context) { here ->
    scope.launch { drawRoute(map, from = here, to = destination) }
}
tracker.start()   // nhớ gọi tracker.stop() trong onDestroy

alternativeRoutes chỉ nhận đúng 2 waypoint. Với lộ trình nhiều chặng, hãy dùng route(waypoints) và truyền danh sách từ 2 waypoint trở lên.

Dẫn đường từng ngã rẽ (Turn-by-turn Navigation)

Chế độ “đang di chuyển”: tuyến sáng ở phía trước và mờ dần ở phần đã đi qua, biểu tượng phương tiện chạy mượt, camera nghiêng và bám theo.

API showNavRoute · updateNavProgress Camera tilt 50° Nội suy do SDK đảm nhiệm

Bắt đầu một phiên dẫn đường#

NavSession.kt
class NavSession(
    private val map: OtsMapView,
    private val route: RouteResult,
    private val carBitmap: Bitmap,
) {
    private val total = route.distanceMeters

    fun start() {
        map.setStyle(MapStyle.NAVIGATION_DAY) {
            map.showNavRoute(route.geometry, zoom = 17.0, tilt = 50.0)
            map.setVehicleAvatar(
                bitmap = carBitmap,
                position = route.geometry.first(),
                bearingDegrees = 0.0,
            )
        }
    }

    // gọi mỗi khi có tọa độ GPS mới
    fun onFix(position: LatLng, bearingDeg: Double, travelledMeters: Double) {
        map.updateNavProgress(
            position = position,
            bearingDeg = bearingDeg,
            fractionTraveled = (travelledMeters / total).coerceIn(0.0, 1.0),
            durationMs = 1000,   // nhịp cập nhật; SDK nội suy mượt trong khoảng thời gian này
        )
    }

    fun stop() {
        map.clearNavRoute()
        map.clearVehicleAvatar()
    }
}

Nối với luồng tọa độ#

Kotlin
val session = NavSession(map, route, carBitmap).also { it.start() }

val tracker = LocationTracker.create(
    context = this,
    minTimeMs = 1000,          // dẫn đường cần nhịp cập nhật dày hơn mặc định
    minDistanceMeters = 2f,
) { fix ->
    session.onFix(fix, bearingDeg = lastBearing, travelledMeters = travelled)
}
tracker.start()

// khi kết thúc phiên
tracker.stop(); session.stop()

Nếu người dùng kéo hoặc phóng to bản đồ trong lúc dẫn đường, camera sẽ nhả chế độ bám theo để họ xem tự do, rồi tự căn lại về phương tiện sau khoảng 6 giây — bạn không phải xử lý gì thêm.

Cần chỉ dẫn rẽ dạng văn bản? Hãy đọc route.steps (maneuverType, modifier, roadName) hoặc dùng nav.rawRoute(...) để nạp vào engine turn-by-turn riêng.

Vị trí của tôi và chế độ bám theo

Xin runtime permission đúng cách, hiển thị location puck và bật/tắt chế độ camera theo dõi vị trí người dùng (Follow Mode).

API enableMyLocation · setFollowMode Permission FINE_LOCATION

Xin quyền vị trí trước, bật tính năng sau#

MyLocationButton.kt
@Composable
fun MyLocationButton(map: OtsMapView) {
    var following by remember { mutableStateOf(false) }

    val permission = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) { map.enableMyLocation(follow = true); following = true }
    }

    FloatingActionButton(onClick = {
        if (hasLocationPermission()) {
            following = !following
            if (following) map.enableMyLocation(follow = true) else map.setFollowMode(false)
        } else {
            permission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
        }
    }) {
        Icon(painterResource(R.drawable.ic_my_location), contentDescription = "Vị trí của tôi")
    }
}

private fun Context.hasLocationPermission() =
    ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
        PackageManager.PERMISSION_GRANTED

Gọi enableMyLocation() khi chưa có permission sẽ không làm gì cả: ứng dụng không crash nhưng cũng không hiển thị location puck. Hãy luôn gọi lại sau khi người dùng chấp thuận.

Dọn dẹp#

Kotlin
map.setFollowMode(false)    // vẫn giữ location puck, chỉ tắt chế độ bám theo
map.disableMyLocation()   // tắt hẳn — gọi khi rời màn hình bản đồ

Chỉ lấy tọa độ, không hiện chấm vị trí#

Kotlin
val tracker = LocationTracker.create(context) { latLng ->
    viewModel.onLocation(latLng)
}
tracker.start()   // phát ngay tọa độ gần nhất, sau đó cập nhật liên tục
// …
tracker.stop()    // luôn gọi stop để giải phóng listener