A Kotlin Compose Multiplatform library providing Google Maps integration for Android and iOS with a unified API. Designed as a drop-in multiplatform replacement for android-maps-compose.
Add the dependency to your build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("eu.buney.maps:kmp-maps-compose:0.2.0")
}
}
}Get a Google Maps API key from Google Cloud Console.
Each platform requires the API key to be configured differently:
- Android: Add a
<meta-data>entry in yourAndroidManifest.xml - iOS: Call
GMSServices.provideAPIKey(key)before using maps
BuildKonfig is a convenient way to manage secrets in KMP projects. Here's a complete setup:
1. Add BuildKonfig plugin to your app's build.gradle.kts:
plugins {
// ...
id("com.codingfeline.buildkonfig") version "0.15.1"
}2. Create secrets.properties in your project root (add to .gitignore):
MAPS_API_KEY=your_api_key_here3. Configure BuildKonfig and Android manifest in your app's build.gradle.kts:
import java.util.Properties
// load secrets
val secretsFile = rootProject.file("secrets.properties")
val secrets = Properties().apply {
if (secretsFile.exists()) load(secretsFile.inputStream())
}
val mapsApiKey = secrets.getProperty("MAPS_API_KEY", "")
android {
defaultConfig {
// make key available in AndroidManifest.xml as ${MAPS_API_KEY}
manifestPlaceholders["MAPS_API_KEY"] = mapsApiKey
}
}
buildkonfig {
packageName = "com.example.myapp"
defaultConfigs {
buildConfigField(STRING, "MAPS_API_KEY", mapsApiKey)
}
}4. Add to AndroidManifest.xml:
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
</application>5. Initialize iOS in your MainViewController.kt:
import GoogleMaps.GMSServices
fun MainViewController() = ComposeUIViewController(
configure = {
GMSServices.provideAPIKey(BuildKonfig.MAPS_API_KEY)
}
) {
App()
}The iOS app requires the Google Maps iOS SDK to be linked. There are two ways to set this up:
Option 1: Use the exported bridge package (Recommended)
Add kmp-maps-compose/exportedGoogleMapsBridge as a local Swift Package in your Xcode project:
- In Xcode, go to File → Add Package Dependencies...
- Click Add Local...
- Navigate to
kmp-maps-compose/exportedGoogleMapsBridgeand add it
This bridge package is auto-generated by the spm4kmp plugin and ensures version consistency between the SDK version expected by the library and what gets linked into your app.
Option 2: Add Google Maps iOS SDK directly
Add the SDK via SPM in your Xcode project:
- In Xcode, go to File → Add Package Dependencies...
- Enter:
https://github.com/googlemaps/ios-maps-sdk - Select the version matching what the library expects (check
exportedGoogleMapsBridge/Package.swiftfor the exact version)
Note: With this approach, you're responsible for keeping the SDK version in sync with library updates.
import eu.buney.maps.*
@Composable
fun MapScreen() {
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition(
target = LatLng(37.7749, -122.4194), // San Francisco
zoom = 12f
)
}
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
properties = MapProperties(mapType = MapType.NORMAL),
uiSettings = MapUiSettings(zoomControlsEnabled = true),
onMapClick = { latLng -> println("Clicked: $latLng") }
) {
Marker(
state = rememberUpdatedMarkerState(position = LatLng(37.7749, -122.4194)),
title = "San Francisco",
snippet = "Welcome!"
)
}
}GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = rememberCameraPositionState(),
properties = MapProperties(
mapType = MapType.HYBRID,
isTrafficEnabled = true,
isBuildingEnabled = true
),
uiSettings = MapUiSettings(
compassEnabled = true,
rotationGesturesEnabled = true
),
contentPadding = PaddingValues(bottom = 80.dp),
onMapClick = { latLng -> /* handle click */ },
onMapLongClick = { latLng -> /* handle long click */ },
onPOIClick = { poi -> /* handle POI click */ },
onMapLoaded = { /* map ready */ }
) {
// add markers, polylines, etc.
}// basic marker
Marker(
state = rememberUpdatedMarkerState(position = LatLng(37.7749, -122.4194)),
title = "Title",
snippet = "Description",
alpha = 0.8f,
rotation = 45f,
draggable = true,
onClick = { marker -> false } // return true to consume
)
// custom info window
MarkerInfoWindow(
state = rememberUpdatedMarkerState(position = latLng),
onClick = { false }
) { marker ->
Column(
modifier = Modifier.background(Color.White).padding(8.dp)
) {
Text("Custom Window", fontWeight = FontWeight.Bold)
Text("Any Compose content!")
}
}
// compose content as marker icon
MarkerComposable(
keys = arrayOf(count),
state = rememberUpdatedMarkerState(position = latLng)
) {
Box(
modifier = Modifier
.size(40.dp)
.background(Color.Red, CircleShape),
contentAlignment = Alignment.Center
) {
Text("$count", color = Color.White)
}
}// simple polyline
Polyline(
points = listOf(LatLng(37.77, -122.42), LatLng(37.78, -122.41)),
color = Color.Blue,
width = 5f,
geodesic = true
)
// gradient polyline
Polyline(
points = routePoints,
spans = listOf(
StyleSpan.gradient(Color.Green, Color.Yellow, segments = 1.0),
StyleSpan.gradient(Color.Yellow, Color.Red, segments = 1.0),
),
width = 8f
)
// stamped polyline (textured pattern)
val stampImage = rememberBitmapDescriptor(Res.drawable.arrow_stamp)
Polyline(
points = routePoints,
spans = listOf(
StyleSpan(
style = StrokeStyle.SolidColor(Color.Blue),
stampStyle = StampStyle(stampImage),
segments = routePoints.size.toDouble()
)
),
width = 16f
)
// polygon with holes
Polygon(
points = outerPoints,
holes = listOf(holePoints),
fillColor = Color.Red.copy(alpha = 0.3f),
strokeColor = Color.Red,
strokeWidth = 2f
)
// circle
Circle(
center = LatLng(37.7749, -122.4194),
radius = 1000.0, // meters
fillColor = Color.Blue.copy(alpha = 0.2f),
strokeColor = Color.Blue
)
// ground overlay
GroundOverlay(
position = GroundOverlayPosition.create(bounds),
image = BitmapDescriptorFactory.fromEncodedImage(imageBytes),
transparency = 0.3f
)val cameraPositionState = rememberCameraPositionState()
// animate to position
LaunchedEffect(targetLocation) {
cameraPositionState.animate(
CameraPosition(target = targetLocation, zoom = 15f),
durationMs = 1000
)
}
// animate to bounds
LaunchedEffect(markers) {
val bounds = LatLngBounds.Builder().apply {
markers.forEach { include(it.position) }
}.build()
cameraPositionState.animateToBounds(bounds, padding = 100)
}
// instant move
cameraPositionState.move(CameraPosition(target = latLng, zoom = 10f))
// check camera state
if (cameraPositionState.isMoving) {
// camera is animating or user is panning
}// from Compose Resources (recommended)
val icon = rememberBitmapDescriptor(Res.drawable.my_marker)
// from PNG/JPEG bytes
val icon = BitmapDescriptorFactory.fromEncodedImage(imageBytes)
// from Compose content (rendered to bitmap)
val icon = rememberComposeBitmapDescriptor(key1, key2) {
Icon(Icons.Default.Place, contentDescription = null, tint = Color.Red)
}
Marker(
state = markerState,
icon = icon
)This table shows feature compatibility between android-maps-compose and this library.
| Feature | android-maps-compose | kmp-maps-compose | Notes |
|---|---|---|---|
| GoogleMap composable | Yes | Yes | |
| MapProperties | Yes | Yes | |
| MapUiSettings | Yes | Yes | Some controls Android-only |
| MapType (Normal, Satellite, Hybrid, Terrain) | Yes | Yes | Terrain falls back to Normal on iOS |
| Content padding | Yes | Yes | |
| onMapClick / onMapLongClick | Yes | Yes | |
| onPOIClick | Yes | Yes | |
| onMapLoaded | Yes | Yes | |
| mapStyleOptions (custom JSON styling) | Yes | No | |
| latLngBoundsForCameraTarget | Yes | No | |
| MapColorScheme | Yes | No | |
| LocationSource | Yes | No |
| Feature | android-maps-compose | kmp-maps-compose | Notes |
|---|---|---|---|
| CameraPositionState | Yes | Yes | |
| CameraPosition (target, zoom, bearing, tilt) | Yes | Yes | |
| animate() | Yes | Yes | |
| animateToBounds() | Yes | Yes | |
| move() | Yes | Yes | |
| isMoving | Yes | Yes | |
| cameraMoveStartedReason | Yes | Yes | |
| projection | Yes | Yes | |
| visibleBounds | Yes | Yes |
| Feature | android-maps-compose | kmp-maps-compose | Notes |
|---|---|---|---|
| Marker | Yes | Yes | |
| MarkerInfoWindow | Yes | Yes | |
| MarkerInfoWindowContent | Yes | Yes | |
| MarkerComposable | Yes | Yes | |
| AdvancedMarker (PinConfig) | Yes | No | |
| MarkerState | Yes | Yes | |
| showInfoWindow / hideInfoWindow | Yes | Yes | |
| Draggable markers | Yes | Yes | |
| Custom BitmapDescriptor icons | Yes | Yes | |
| onClick, onInfoWindowClick, etc. | Yes | Yes |
| Feature | android-maps-compose | kmp-maps-compose | Notes |
|---|---|---|---|
| Polyline | Yes | Yes | |
| Polyline pattern/cap/jointType | Yes | Partial | Android only |
| Polygon | Yes | Yes | |
| Polygon holes | Yes | Yes | |
| Polygon pattern/jointType | Yes | Partial | Android only |
| Circle | Yes | Yes | |
| Circle pattern | Yes | Partial | Android only |
| GroundOverlay | Yes | Yes | |
| TileOverlay | Yes | No |
| Feature | android-maps-compose | kmp-maps-compose | Notes |
|---|---|---|---|
| StreetView | Yes | No | |
| Clustering | Yes | No | |
| ScaleBar widget | Yes | No | |
| MapEffect | Yes | No | |
| IndoorStateChangeListener | Yes | No |
Legend: Yes = Supported | Partial = See notes | No = Not supported
- Uses Google Maps iOS SDK via Swift Package Manager
- Some styling features not supported by iOS SDK:
- Polyline/polygon stroke patterns
- Line caps and joint types
- Map toolbar and zoom controls
- TERRAIN map type falls back to NORMAL
- Z-index values are truncated to integers
- Custom info window Compose content rendered as bitmap
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
