Skip to content
This site is a preview of pull request #1384.

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.animateCameraPosition is a suspend function that moves the map to a new position. Call it from a coroutine. The function waits until the map is attached, and returns when the transition ends:

App.kt
LaunchedEffect(mapState) {
mapState.animateCameraPosition(
position =
mapState.cameraPosition.copy(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 is the default. 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.animateCameraPosition(
position =
CameraPosition(target = Position(latitude = 40.713, longitude = -74.006), zoom = 12.0),
animation = CameraAnimation.Fly(duration = 3.seconds, minZoom = 4.0),
)
}

CameraAnimation.Ease 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.animateCameraPosition(
position = mapState.cameraPosition.copy(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. Omitted camera components keep their starting values. 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 that padding, resizing the logical viewport, or losing the attachment cancels the animation. The operation does not restart on a replacement attachment. 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. Padding adds space between the box and the map edges:

App.kt
LaunchedEffect(mapState) {
mapState.animateCameraToBounds(
boundingBox = BoundingBox(west = -123.0, south = 47.0, east = -122.0, north = 48.0),
padding = PaddingValues(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),
padding = PaddingValues(32.dp),
)
mapState.animateCameraPosition(camera.copy(zoom = minOf(camera.zoom, 12.0)))
}

On the browser, bounds fitting calculates the target and zoom without tilt, then assigns the requested tilt. With nonzero tilt, some bounds may fall outside the viewport.

MapState.viewport reports the current rendered size, visible bounds, and visible region. It is null until the map renders its first viewport. A composition that reads it 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.