Compose overview¶
yandex-mapkit-kmp-compose draws the map from common code with Compose Multiplatform. It offers two
APIs, and they can be mixed within one screen only through MapEffect.
| When to use | |
|---|---|
| States API | almost always — declarative map objects, camera and configuration |
| Controller API | when you already have imperative MapKit code and only need a view |
States API¶
YandexMap() creates the MapView and runs its content as a second composition whose nodes
are map objects. That composition has its own applier, so it accepts only composables marked
@YandexMapComposable — a Text or a Box inside it will not compile.
Simple setup¶
@Composable
fun MapScreen() {
rememberAndInitializeMapKit().bindToLifecycleOwner()
val cameraPositionState = rememberCameraPositionState { position = startPosition }
YandexMap(
cameraPositionState = cameraPositionState,
modifier = Modifier.fillMaxSize(),
)
}
rememberAndInitializeMapKit().bindToLifecycleOwner() is needed once per screen and can be dropped
if MapKit is initialized elsewhere — see Quick start.
The camera¶
CameraPositionState is the single source of truth for where the camera is. Reading position
recomposes when the camera moves; writing it moves the camera.
val cameraPositionState = rememberCameraPositionState { position = startPosition }
LaunchedEffect(selected) {
cameraPositionState.position = CameraPosition(selected, zoom = 16f, azimuth = 0f, tilt = 0f)
}
if (cameraPositionState.isMoving) {
LinearProgressIndicator()
}
isMoving and updateReason (GESTURES or APPLICATION) tell what the camera is doing and who
started it — a gesture from the user or your own code.
One CameraPositionState per YandexMap
Sharing a state object between two maps makes them fight over the camera.
Inside map content, currentCameraPositionState reaches the state of the enclosing map without
threading it through.
Configuring the map¶
MapConfig covers Map and MapWindow. Every property is nullable, and null means "leave it to
MapKit".
YandexMap(
cameraPositionState = cameraPositionState,
config = MapConfig(
isNightModeEnabled = isSystemInDarkTheme(),
poiLimit = 5,
isRotateGesturesEnabled = false,
isTiltGesturesEnabled = false,
isScrollGesturesEnabled = true,
isZoomGesturesEnabled = true,
mapType = MapType.VECTOR_MAP,
mode = MapMode.MAP,
use2dMode = true,
isHdModeEnabled = true,
isIndoorEnabled = true,
mapStyle = """[{"tags": {"any": ["building"]}, "stylers": {"visibility": "off"}}]""",
focusRect = ScreenRect(ScreenPoint(0f, 0f), ScreenPoint(width, height * 0.6f)),
pointOfView = PointOfView.SCREEN_CENTER,
scaleFactor = 1f,
fps = 30,
cameraBounds = MapCameraBoundsConfig(
minZoomPreference = 10f,
maxZoomPreference = 18f,
),
logo = MapLogoConfig(
alignment = LogoAlignment(
horizontalAlignment = LogoHorizontalAlignment.LEFT,
verticalAlignment = LogoVerticalAlignment.BOTTOM,
),
padding = LogoPadding(horizontalPadding = 16, verticalPadding = 16),
),
),
modifier = Modifier.fillMaxSize(),
)
null is not the same as a default
null means the wrapper does not touch that property at all. Setting a value and then putting
null back does not restore MapKit's default — it just stops updating it.
The default MapConfig follows the system theme: MapConfig(isNightModeEnabled = isSystemInDarkTheme()).
Map events¶
MapListeners subscribes for as long as it stays in the composition. Every callback is optional, and
the matching MapKit listener is attached only when it is not null.
YandexMap(modifier = Modifier.fillMaxSize()) {
MapListeners(
onMapTap = { point -> println("tap at $point") },
onMapLongTap = { point -> println("long tap at $point") },
onGeoObjectTap = { event ->
val metadata = event.geoObject.selectionMetadata ?: return@MapListeners false
selection = metadata
true
},
onMapLoaded = { statistics -> println("loaded in ${statistics.fullyLoaded}") },
onMapWindowSizeChanged = { width, height -> println("$width x $height") },
onActivePlanFocused = { plan -> indoorPlan = plan },
onActivePlanLeft = { indoorPlan = null },
onActiveLevelChanged = { levelId -> activeLevel = levelId },
)
}
Returning false from onGeoObjectTap lets the event propagate to the map, which then also reaches
onMapTap.
User location¶
The YandexMap overload that takes a UserLocationState creates and owns the
UserLocationLayer:
@Composable
fun MapScreen() {
val locationState = rememberUserLocationState()
YandexMap(
locationState = locationState,
locationConfig = UserLocationConfig(
isVisible = true,
isHeadingEnabled = true,
isAutoZoomEnabled = true,
anchor = UserLocationConfig.Anchor(
normal = PointF(width * 0.5f, height * 0.5f),
course = PointF(width * 0.5f, height * 0.75f),
),
pin = UserLocationConfig.LocationIcon(image = pinImage),
arrow = UserLocationConfig.LocationIcon(image = arrowImage),
accuracy = UserLocationConfig.LocationAccuracy(fillColor = Color(0x334CAF50)),
),
modifier = Modifier.fillMaxSize(),
)
}
locationState.cameraPosition follows the layer's own camera position, or is null while there is
no fix. Permissions are still yours to request — see Geolocation.
Traffic¶
var level by remember { mutableStateOf<TrafficLevel?>(null) }
YandexMap(modifier = Modifier.fillMaxSize()) {
TrafficLayer(
visible = true,
onTrafficChanged = { level = it },
onTrafficLoading = { },
onTrafficExpired = { },
)
}
Grouping map objects¶
MapObjectCollection puts its content into a nested collection. The collection is a map object
itself, so visible hides the whole group at once and zIndex applies to all of it; leaving the
composition removes the group from the map.
MapObjectCollectionState reaches what a parameter cannot express — the PlacemarksStyler shared
by every placemark of the group, and traverse.
@Composable
fun MapScreen(showPois: Boolean) {
val poisState = rememberMapObjectCollectionState()
YandexMap(modifier = Modifier.fillMaxSize()) {
MapObjectCollection(state = poisState, visible = showPois, zIndex = 1f) {
pois.forEach { poi ->
Placemark(state = rememberPlacemarkState(poi.point), icon = icon)
}
}
}
LaunchedEffect(Unit) {
poisState.setPlacemarksScaleFunction(
listOf(PointF(10f, 0.5f), PointF(16f, 1f)),
)
}
}
Custom tile layers¶
TileLayer adds a layer of your own tiles and removes it when it leaves the composition.
layerId names the layer and picks its place in the render order; createTileDataSource is called
once per layer, so a data source does not follow later recompositions.
YandexMap(modifier = Modifier.fillMaxSize()) {
TileLayer(
layerId = "weather",
options = LayerOptions(transparent = true, nightModeAvailable = false),
active = true,
onLayerLoaded = { println("weather tiles are on screen") },
onDataSourceUpdated = { },
) { builder ->
builder.setTileUrlProvider(
UrlProvider { tileId, _, _ ->
"https://tiles.example.com/${tileId.z}/${tileId.x}/${tileId.y}.png"
},
)
builder.setProjection(Projections.wgs84Mercator)
builder.setZoomRanges(listOf(ZoomRange(zMin = 0, zMax = 19)))
builder.setTileFormat(TileFormat.PNG)
}
}
MapObjectLayer does the same for map objects: its content goes into an independent collection
linked to the given layer instead of the default one.
MapObjectLayer(layerId = "my-routes") {
Polyline(state = rememberPolylineState(route))
}
The collection is reused
MapKit links one collection to a layer id for the lifetime of the map and cannot unlink it, so the collection is created once per id and reused: leaving the composition empties it, entering it again fills the same one.
Escaping to MapKit¶
MapEffect hands over the Map instance the composition is driving, for anything the composables
do not cover. It is @YandexMapComposable and only runs inside YandexMap content.
YandexMap(
cameraPositionState = cameraPositionState,
modifier = Modifier.fillMaxSize(),
) {
MapEffect(selection) { map ->
val metadata = selection
if (metadata != null) map.selectGeoObject(metadata) else map.deselectGeoObject()
}
}
Like LaunchedEffect, it restarts when its keys change and runs in a coroutine scope tied to the
map composition.
Controller API¶
YandexMap() also has an overload that takes a YandexMapController and does nothing else: no
composition of map objects, no config, no camera state. You get the MapWindow and drive MapKit
imperatively.
@Composable
fun MapScreen() {
rememberAndInitializeMapKit().bindToLifecycleOwner()
val mapController = rememberYandexMapController()
YandexMap(
controller = mapController,
modifier = Modifier.fillMaxSize(),
)
}
YandexMapController.mapWindow is null until the view has been created.
MapControllerEffect runs once the MapWindow exists, and hands it over non-null:
@Composable
fun MapScreen() {
val mapController = rememberYandexMapController()
MapControllerEffect(mapController) { mapWindow ->
mapWindow.map.move(startPosition)
mapWindow.map.isZoomGesturesEnabled = true
}
YandexMap(
controller = mapController,
modifier = Modifier.fillMaxSize(),
)
}
Everything on Map and camera and Map objects applies inside that block — including the rule that listeners must be held in a field, not created inline.
What is next¶
- Map objects — placemarks, polylines, polygons, circles, clustering.
- Image resources — icons from Compose resources and from composable content.