Skip to content

Control the camera

The camera defines the visible part of the map: a target position, a zoom level, a bearing, and a tilt. rememberMapState creates a MapState with a read-only camera position. Pass an initial CameraPosition to set the camera at startup:

App.kt
val mapState =
rememberMapState(
initialCameraPosition =
CameraPosition(target = Position(latitude = 45.521, longitude = -122.675), zoom = 13.0)
)
MaplibreMap(state = mapState)

MapState.animateCamera is a suspend function that moves only the properties specified in a CameraUpdate. Call it from a coroutine. The function waits for an attached viewport and returns when its properties finish or are superseded:

App.kt
LaunchedEffect(mapState) {
mapState.animateCamera(
update = CameraUpdate(target = Position(latitude = 47.607, longitude = -122.342))
)
}

Use MapState.setCameraPosition to move without an animation. The position is retained when the map is detached and restored when it is displayed again.

The animation parameter selects one of two transitions. Each platform implements both with the same controls.

CameraAnimation.Fly follows a flight path. The camera zooms out, crosses the ground, and zooms back in, so the map stays legible over any distance. Without a duration, the flight takes as long as its path length and speed require. Set duration for a fixed time, speed in screenfuls per second for a constant pace, and minZoom to keep the flight path from zooming out past a zoom. The path peaks near minZoom rather than stopping exactly at it:

App.kt
LaunchedEffect(mapState) {
mapState.animateCamera(
update = CameraUpdate(target = Position(latitude = 40.713, longitude = -74.006), zoom = 12.0),
animation = CameraAnimation.Fly(duration = 3.seconds, minZoom = 4.0),
)
}

CameraAnimation.Ease is the default. It moves the camera directly to its target over a fixed duration, without zooming out on the way. Use it for short moves, such as changing zoom in place or following a location:

App.kt
LaunchedEffect(mapState) {
mapState.animateCamera(
update = CameraUpdate(zoom = mapState.cameraPosition.zoom + 1.0),
animation = CameraAnimation.Ease(duration = 500.milliseconds),
)
}

Both transitions accept an easing timing curve, a CubicBezier. On Android, the system animator duration scale multiplies the duration of either transition. A scale of zero jumps to the target.

MapState.animateCameraAround changes zoom, bearing, or tilt while keeping a point at its screen location. Use CameraAnchor.Screen for coordinates in dp from the full map’s top-left corner:

App.kt
LaunchedEffect(mapState) {
mapState.animateCameraAround(
anchor = CameraAnchor.Screen(DpOffset(120.dp, 200.dp)),
zoom = 16.0,
bearing = 90.0,
animation = CameraAnimation.Ease(500.milliseconds),
)
}

Use CameraAnchor.Geographic to keep a selected geographic location at its current screen point. The anchor is resolved when the animation starts and must be visible on the map. This operation supports easing; it does not accept a flight or a target center because the center moves to keep the anchor fixed.

Persistent camera padding participates in projection. Changing viewport insets, resizing the logical viewport, or losing the attachment cancels the animation. Camera constraints take precedence over anchor preservation. The preservation guarantee applies to flat Mercator maps, including tilted cameras; it does not extend to globe or terrain.

MapState.animateCameraToBounds fits a BoundingBox in the current viewport with the same animation choices. Fit padding adds space around the box inside the viewport insets and camera padding:

App.kt
LaunchedEffect(mapState) {
mapState.animateCameraToBounds(
boundingBox = BoundingBox(west = -123.0, south = 47.0, east = -122.0, north = 48.0),
fitPadding = DpPadding(left = 32.dp, top = 32.dp, right = 32.dp, bottom = 32.dp),
)
}

Use MapState.fitCameraToBounds to fit the same bounding box without an animation.

Use MapState.cameraForBounds to calculate a position before applying it. The query waits for a viewport and leaves the current camera and animation unchanged. For example, cap the calculated zoom before animating:

App.kt
LaunchedEffect(mapState) {
val camera =
mapState.cameraForBounds(
boundingBox = BoundingBox(west = -123.0, south = 47.0, east = -122.0, north = 48.0),
fitPadding = DpPadding(left = 32.dp, top = 32.dp, right = 32.dp, bottom = 32.dp),
)
mapState.animateCamera(camera.copy(zoom = minOf(camera.zoom, 12.0)).toCameraUpdate())
}

MapState.viewport reports the last rendered camera, size, visible bounds, and visible region, which all describe the same frame. It is null until the map renders its first viewport, while MapState.cameraPosition is available before that. A composition that reads the viewport recomposes when the camera moves or the map resizes:

App.kt
val viewport = mapState.viewport
if (viewport != null) {
Text("Visible bounds: ${viewport.visibleBounds}")
}

Convert between screen and geographic coordinates

Section titled “Convert between screen and geographic coordinates”

MapState.screenLocationFromPosition converts a geographic position to an offset from the top-left corner of the map composable. MapState.positionFromScreenLocation converts in the other direction:

App.kt
val screenOffset = mapState.screenLocationFromPosition(mapState.cameraPosition.target)
val geoPosition = mapState.positionFromScreenLocation(DpOffset(x = 100.dp, y = 150.dp))

MapLibre repeats the world horizontally. Geographic values read from the map, such as Viewport.visibleBounds and MapState.positionFromScreenLocation, preserve the world copy: longitudes may extend past ±180°, and the visible bounds may span more than 360°. VisibleBounds.toBoundingBox() converts to a GeoJSON BoundingBox, where an antimeridian crossing follows RFC 7946 with an east longitude less than the west.