Usage
Here is a basic implementation of KMaP using a raster tile source.
You can find a demo app (including OpenStreetMap sources) in the KMaP repo.
val mapProperties = /* your MapProperties implementation */
val tileSource = /* your TileSource<RasterTile> implementation */
val mapState = rememberMapState(mapProperties = mapProperties)
KMaP(
modifier = Modifier.fillMaxSize(),
mapState = mapState,
) {
rasterCanvas(
parameters = RasterCanvasParameters(
id = 1,
tileSource = tileSource::getTile,
),
gestureWrapper = MapGestureWrapper(
onGesture = { centroid, pan, zoom, rotation ->
mapState.motionController.move {
rotateByCentered(rotation.toDouble(), centroid)
zoomByCentered(zoom, centroid)
positionBy(pan)
}
},
)
)
}
How it Works
There are a few core pieces in KMaP: MotionController, MapState, and KMaPContent.
- MotionController: Handles movement like zooming, panning, and rotating for either user or app input.
- MapState: Holds map properties and camera state, and resolves visible tiles for rendering.
- KMaPContent: The DSL scope where you declare raster/vector canvases, markers, clusters, and paths.
MapState
This is where most of the important state lives. It expects an implementation of the MapProperties interface that describes how the map behaves, for example, tile size, zoom range, and projection transforms.
interface MapProperties {
val boundMap: BoundMapBorder
val outsideTiles: OutsideTilesType
val zoomLevels: ZoomLevelRange
val coordinatesRange: CoordinatesRange
val tileSize: TileDimension
fun toProjectedCoordinates(coordinates: Coordinates): ProjectedCoordinates
fun toCoordinates(projectedCoordinates: ProjectedCoordinates): Coordinates
}
data class CameraState(
val canvasSize: ScreenOffset = ScreenOffset.Zero,
val zoom: Float = 0F,
val angleDegrees: Double = 0.0,
val coordinates: Coordinates,
)
MotionController
It's responsible for handling the movement of the map like zooming, panning, and rotating for either user input or app input.
It has two movement options:
- Move: Sets or adjusts the provided values on the map parameters.
- Animate: Animates the map parameters to the provided values.
Move is synchronous; Animate is async and must be launched from a coroutine.
Reference
Before using move and animate, it helps to know the reference system. Reference is the base class for the coordinate types used by the API.
open class Reference
class ScreenOffset : Reference
class TilePoint : Reference
class Coordinates : Reference
class ProjectedCoordinates : Reference
class DifferentialScreenOffset : Reference
- ScreenOffset represents a screen offset with (0, 0) located on the top-left part of the main KMaP composable
- TilePoint represents a point on the map with projection and scaled to the tile
- Coordinates represent a coordinate of the map
- ProjectedCoordinates represent a coordinate of the map with its projection
- DifferentialScreenOffset is the same as ScreenOffset but represents a delta
Move
Move has its own scoped interface, so it can only perform the following actions
interface MoveInterface {
fun positionTo(center: Reference)
fun positionBy(center: Reference)
fun zoomTo(zoom: Float)
fun zoomBy(zoom: Float)
fun zoomToCentered(zoom: Float, center: Reference)
fun zoomByCentered(zoom: Float, center: Reference)
fun rotateTo(degrees: Double)
fun rotateBy(degrees: Double)
fun rotateToCentered(degrees: Double, center: Reference)
fun rotateByCentered(degrees: Double, center: Reference)
}
Here are some examples of how to use MotionController.move. See the comments to understand what each line does.
mapState.motionController.move {
positionTo(ScreenOffset.Zero) # (1)!
positionTo(TilePoint.Zero) # (2)!
positionTo(Coordinates.Zero) # (3)!
zoomBy(5F) # (4)!
zoomToCentered(7F, TilePoint.Zero) # (5)!
rotateByCentered(45.0, ScreenOffset.Zero) # (6)!
}
- Center the current-provided point of the screen in the center of the screen
- Center provided position of the canvas, without projection, in the center of the screen
- Center provided projection in the center of the screen
- Set the zoom amount
- Set the zoom amount while maintaining the provided point of the same place on the screen
- Rotate the canvas around a provided pivot point
Animate
Now here is the scoped interface with the functions that can be implemented with Animate and an example
interface AnimateInterface {
suspend fun positionTo(center: Reference, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun positionBy(center: Reference, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun zoomTo(zoom: Float, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun zoomBy(zoom: Float, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun zoomToCentered(zoom: Float, center: Reference, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun zoomByCentered(zoom: Float, center: Reference, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun rotateTo(degrees: Double, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun rotateBy(degrees: Double, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun rotateToCentered(degrees: Double, center: Reference, animationSpec: AnimationSpec<Float> = SpringSpec())
suspend fun rotateByCentered(degrees: Double, center: Reference, animationSpec: AnimationSpec<Float> = SpringSpec())
}
Animate has similar scoped functions to move with a key difference; it adds an animationSpec where you can define how the animation will be performed.
scope.launch {
mapState.motionController.animate {
positionTo(Coordinates(0.0, 0.0), TweenSpec(2000))
positionTo(Coordinates(180.0, 90.0), TweenSpec(2000))
positionTo(Coordinates(45.0, 0.0), TweenSpec(2000))
zoomBy(1F, TweenSpec(2000))
zoomBy(-1F, TweenSpec(2000))
zoomToCentered(1F, Coordinates(0.0, 0.0), TweenSpec(2000))
rotateBy(360.0, TweenSpec(2000))
}
mapState.motionController.animate {
rotateByCentered(-360.0, Coordinates(0.0, 0.0), TweenSpec(2000))
}
}
Raster and Vector Canvas
KMaP exposes two canvas types in the KMaP scope:
- rasterCanvas: Renders raster tiles.
- vectorCanvas: Renders vector tiles and requires a style.
Both rely on a TileSource implementation:
interface TileSource<T : Tile> {
suspend fun getTile(zoom: Int, row: Int, column: Int): TileResult<T>
}
Use RasterCanvasParameters or VectorCanvasParameters when calling rasterCanvas or vectorCanvas. Vector canvases also take an OptimizedStyle.
With a tile source you can render any tiled map you want: OSM, custom servers, offline tiles, or device-generated tiles.
You can pass a MapGestureWrapper to handle input; KMaP wires it into the pointer input scope:
MapGestureWrapper(
onDoubleTap = { offset -> mapState.motionController.move { zoomByCentered(-1 / 3F, offset) } },
onTapSwipe = { zoomChange, rotationChange ->
mapState.motionController.move {
zoomBy(zoomChange / 120F)
rotateBy(rotationChange)
}
},
onTwoFingersTap = { offset -> mapState.motionController.move { zoomByCentered(1 / 3F, offset) } },
onGesture = { centroid, pan, zoom, rotation ->
mapState.motionController.move {
rotateByCentered(rotation.toDouble(), centroid)
zoomByCentered(zoom, centroid)
positionBy(pan)
}
},
onScroll = { mouseOffset, scrollAmount ->
mapState.motionController.move { zoomByCentered(scrollAmount, mouseOffset) }
},
)
See the Slippy map docs to better understand how it works.
Markers
Markers are a powerful way to draw anything on the map. Instead of a bitmap or vector like other libraries, KMaP uses a composable so you can draw whatever you want. Declare it in the KMaP scope.
marker(
marker = MarkerParameters(
Coordinates(0.0, 0.0),
drawPosition = DrawPosition.TOP_RIGHT,
)
) {
Text(
text = "Fixed size",
modifier = Modifier
.background(Color.Black)
.padding(16.dp),
color = Color.White
)
}
Use the markers() API to draw a list of markers with the provided composable. There are a lot of options for how to handle markers; feel free to experiment. Below is the data class that stores the options and its default values.
open class MarkerParameters(
val coordinates: Coordinates,
val alpha: Float = 1F,
val drawPosition: DrawPosition = DrawPosition.TOP_LEFT,
val zIndex: Float = 2F,
val zoomVisibilityRange: ClosedFloatingPointRange<Float> = 0F..Float.MAX_VALUE,
val zoomToFix: Float? = null,
val rotateWithMap: Boolean = false,
val rotation: Degrees = 0.0,
val clusterId: Int? = null
) : Parameters
Clusters
With clusters, you can merge overlapping markers. Declare it in the KMaP scope with the clusterId of the markers you want to group.
cluster(
ClusterParameters(id = 1)
) {
Text(
text = "Cluster tag 1",
modifier = Modifier
.background(Color.Green)
.padding(16.dp),
color = Color.White
)
}
Cluster composables do not behave the same as markers, so you have to define their behavior with parameters. Here is the data class with the options.
open class ClusterParameters(
val id: Int,
val alpha: Float = 1F,
val zIndex: Float = 2F,
val rotateWithMap: Boolean = false,
val rotation: Degrees = 0.0
) : Parameters
Path
Paths are similar to markers in that they are also composable. The key difference is that to be "clickable" you need a
pointer input scope that shares input with its siblings, otherwise taps outside the path will stop the map from responding.
Check SharedSuspendingPointerInput.kt in the project if you want to dive into how it works.
val path1 = PathData {
moveTo(0F, 0F)
lineTo(180F, -80F)
lineTo(90F, -80F)
lineTo(90F, 0F)
}.toPath()
path(
parameters = PathParameters(
path = path1,
color = Color.Red,
style = Stroke(
width = 4F,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
),
),
gestureWrapper = PathGestureWrapper(
onTap = {
markerCoordinates = with(mapState) {
it.toTilePoint().toCoordinates()
}
},
)
)
⚠️ Important Note: When defining path coordinates, you must use projected coordinates. The path points need to be in the same coordinate system as your map projection to render correctly on the canvas.
Similar to canvas, there are also gestures for the path that can be used.
data class PathGestureWrapper(
val onTap: ((ProjectedCoordinates) -> Unit)? = null,
val onDoubleTap: ((ProjectedCoordinates) -> Unit)? = null,
val onLongPress: ((ProjectedCoordinates) -> Unit)? = null,
val onHover: ((ProjectedCoordinates) -> Unit)? = null,
)