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.
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.
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
}
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.
private val geo = GeocodingClient.create()
map.setEventListener(object : MapEventListener {
override funonMapTap(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.
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.
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.
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.
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) } }
Camera 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()
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().
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.
MapSdk.init(...) was never called. Put it in Application.onCreate.
Blank map, no tiles load
Wrong or missing apiKey, or the INTERNET permission is missing.
My Location puck never appears
The location runtime permission was not requested; call enableMyLocation() again after the user grants it.
Map stutters or redraws on rotation
The lifecycle is not fully forwarded. On Compose use rememberOtsMapView().
You want to see which API the SDK calls
Turn on enableHttpLogging = true (debug builds only).
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.
// list the layer ids of the current styleval ids = map.currentStyleLayerIds()
// hide every POI label, enable the traffic layer
map.setLayersVisibilityByIdContains("poi", visible = false)
map.setLayerVisibility("traffic", visible = true)
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.
data classPoi(val name: String, val lat: Double, val lng: Double)
funrenderPois(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
}
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.
Autocomplete + debounce
Suggestions as the user types: 250 ms debounce, results biased around the camera, and a camera animation once one is picked.
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 NavigationClientRender showRouteProfile car · bike · foot
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 · updateNavProgressCamera tilt 50°Interpolation handled by the SDK
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 · setFollowModePermission FINE_LOCATION
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.
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.
<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" />
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
}
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.
private val geo = GeocodingClient.create()
map.setEventListener(object : MapEventListener {
override funonMapTap(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.
Kết quả reverse chỉ có name, address và layer. 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 funshowPoiSheet(place: Place) {
place.location?.let { map.addMarker(MarkerOptions(position = it, title = place.name)) }
val id = place.id ?: returnval 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 Place
Nguồn dữ liệu
layer
"poi" · "address" · "region" … — căn cứ để phân biệt POI với địa chỉ đường
name · address
có ở cả reverse, nearby và place-detail
category · primaryType
place-detail — nhãn đã bản địa hóa (Nhà hàng) và mã loại gốc (restaurant)
phone · website · email
chỉ có ở place-detail và nearby; luôn null trong kết quả reverse
viewport
place-detail và nearby — truyền trực tiếp cho map.fitBounds(...)
distanceMeters
khoảng cách tính bằng mét so với điểm truy vấn (reverse và nearby)
timeZone
place-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ị.
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.
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.
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ểu
Mô tả
position
LatLng
Tọa độ; bắt buộc
title
String?
Tiêu đề hiển thị
snippet
String?
Dòng mô tả phụ
iconId
String?
Id icon trong sprite của style, hoặc bitmap đã được đăng ký
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.
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()
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) } }
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.
Camera 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_LOCATIONtrướ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()
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().
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.
// 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")
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ớ:
// liệt kê id các layer của style hiện tạival 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)
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.
// đă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",
)
})
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 đó.
classSearchViewModel : ViewModel() {
private val geo = GeocodingClient.create()
private val query = MutableStateFlow("")
var focus: LatLng? = null// tâm camera hiện tạival 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())
funonQueryChange(text: String) { query.value = text }
}
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 NavigationClientVẽ bằng showRouteProfile car · bike · foot
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 · updateNavProgressCamera tilt 50°Nội suy do SDK đảm nhiệm
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 · setFollowModePermission FINE_LOCATION
@ComposablefunMyLocationButton(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 funContext.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.
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
Esc
↑↓ to navigate↵ to selectEsc to close↑↓ di chuyển↵ mởEsc đóng