Let's Work Together
April 15, 2025 · 19 min read

Compose Multiplatform: A Practically-Native Example: part 2!

Enhancing our Google Maps multiplatform Composable

Author Jonathan Davis
Compose Multiplatform: A Practically-Native Example: part 2!

Compose Multiplatform

A Practically-Native Example: pt 2!

My previous blog post Compose Multiplatform: A Practically-Native Example: is in desperate need of a follow-up. It just didn’t do enough for my taste. Yeah, cool, we can load Google Maps to its default location somewhere above Europe, but how is that supposed to demonstrate the power or benefit of CMP/KMP. So, for part two(✌🏿), I will show you how to place Markers on the map and animate its camera.

It’s assumed that you’ve followed along and completed the code from the previous blog post. All code presented here builds on the foundations of the app were built there.

Code will be provided in snippets/chunks, so you don’t have to read impossibly long walls of text. Imports statements are omitted to keep the code as brief as possible. If you could follow the previous blog post, you shouldn’t have trouble integrating the code here (the structure may differ) 😁.

Initial Location 🤖

Stop the talk; it’s coding time. We’ll start by giving the Map an initial location (Atomic Robot office). We must create a state class to interact with our GMap. This class will hold state that we’ll observe in our composable. We’ll call this class GMapState. Open the GMap.kt and paste the following code at the top of the file:

GMap.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
 * Generic representation for geographic coordinates
 */
data class GMapCoordinate(
    val latitude: Double = 0.00,
    val longitude: Double = 0.00,
)

**
* Location and zoom level the Map will load at start
*/
class InitialCameraState(
    val position: GMapCoordinate,
    val zoomLevel: Double = 14.0,
)

/**
 * Abstraction to interact with the map camera.
 */
expect class GMapCameraPositionState

class GMapsState(
    val initialCameraState: InitialCameraState,
    val cameraState: GMapCameraPositionState,
)

@Composable
expect fun createCameraPositionState(
    initialCameraState: InitialCameraState,
): GMapCameraPositionState

@Composable
fun rememberGMapState(
    initialCameraState: InitialCameraState = InitialCameraState(
        position = GMapCoordinate()
    )
): GMapsState {
    val cameraState = createCameraPositionState(initialCameraState)
    return remember {
        GMapsState(
            initialCameraState = initialCameraState,
            cameraState = cameraState,
            markers = markers,
        )
    }
}

GMapCoordinate is a glorified data marshaller between “common code” and the respective platform’s native representation of geographic coordinates (LatLng & CLLocationCoordinate2D). We will use custom extension functions to transform the types on the fly.

rememberGMapState(InitialCameraState) will create a new GMapState that will hold the initial map position and zoom level (technically, we are just setting the Map’s camera). The expect Composable createCameraPositionState(InitialCameraState) indicates that there should be a matching actual Composable function for each platform for creating an instance of the platform-specific GMapCameraPositionState class. This class serves as an abstraction for interacting with the underlying map camera. The map camera is how you control/change what the map displays. Imagine the Map as a locked-off globe, and we are viewing it through a “camera viewfinder” (map view frame). As you can imagine, the Google Maps API describes moving the Map around as changes in the Map’s camera position. Fear not if you’re worrying about tricky trigonometry 📐 to handle moving the camera around, maintaining the zoom level, or drawing on a spherical earth. For most use cases (ours, at least), the underlying Google Maps API handles all of the tricky bits, and all we have to do is describe what we want in geographical coordinates.


Open GMap.android.kt and copy & paste the below code:

GMap.android.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
actual class GMapCameraPositionState(
    val cameraPositionState: CameraPositionState
)

@Composable
actual fun createCameraPositionState(
    initialCameraState: InitialCameraState,
): GMapCameraPositionState = GMapCameraPositionState(
    cameraPositionState = rememberCameraPositionState {
        position = initialCameraState.toCameraPosition()
    }
)

@Composable
actual fun GMap(
    mapState: GMapsState,
    modifier: Modifier,
) {
    GoogleMap(
        modifier = modifier,
        cameraPositionState = mapState.cameraState.cameraPositionState,
    )
}

/**
 * Convert a [GMapCoordinate] to a [LatLng] (native representation of
 * geographic coordinates).
 */
private fun GMapCoordinate.toLatLng() = LatLng(
    /* latitude = */ this.latitude,
    /* longitude = */ this.longitude,
)

/**
 * Convert a [InitialCameraState] to a [CameraPosition] (sets map
 * camera position).
 */
private fun InitialCameraState.toCameraPosition(): CameraPosition =
    CameraPosition.fromLatLngZoom(
        this.position.toLatLng(),
        this.zoomLevel.toFloat()
    )

The Compose Google Maps API already provides a camera object (CameraPositionState) that we can use from Compose. All we need to do is wrap this type with our actual definition of GMapCameraPositionState.


Open GMap.ios.kt and replace the existing GMap(...) composable with the code below:

GMap.ios.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
 * Controller for interacting with the map's camera.
 */
actual class GMapCameraPositionState

@Composable
actual fun createCameraPositionState(
    initialCameraState: InitialCameraState,
): GMapCameraPositionState = GMapCameraPositionState()

@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun GMap(
    mapState: GMapsState,
    modifier: Modifier,
) {
    // Grab Koin instance
    val koin = getKoin()
    // Protect against recompositions, keep our initial instance
    val mapBridge = remember {
        koin.inject<IGMapsBridge> {
            parametersOf(mapState.initialCameraState)
        }.value
    }

    val map = remember { mapBridge.mapView() }

    UIKitView<UIView>(
        factory = { map },
        modifier = modifier,
        properties = UIKitInteropProperties(
            interactionMode = UIKitInteropInteractionMode.NonCooperative
        ),
    )
}

For now, GMapCameraPositionState has an empty actual class definition. We don’t have a direct camera object we can instantiate and interact with like we do Android. We must use a Software Engineer’s favorite tool: Abstraction. By the end of this blog, we’ll add a state that we’ll “observe” from Compose and pass along “camera updates” to IGMapsBridge. We don’t want to get ahead of ourselves; that will come later. For now, we still need to pass the initial camera state to our Swift code so we can initialize the GMSMapView. We achieve this by passing the initial state as a parameter to the Koin.inject() call:

1
2
3
koin.inject<IGMapsBridge> {
    parametersOf(mapState.initialCameraState)
}

We HAVE to remember the inject call so if/when GMap recomposes, we don’t get a new instance of IGMapsBridge. (Inject doesn’t associate values with a given composition.)

Open PlatformDI.kt, its time to update loadKoinSwiftModules(...). We just need the IGMapsBridge factory lambda to accept an InitialCameraState object:

PlatformDI.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
fun loadKoinSwiftModules(
    gmapsBridgeFactory: (InitialCameraState) -> IGMapsBridge,
) {
    initDI(
        arrayOf(
            module {
                /*
                Register the lambda as factory definition, so if dependent code ever
                needs it, they get a fresh instance of the IGMapsBridge. This is
                important if the implementation of IGMapsBridge caches
                an instance of the GMSMapView.
                */
                factory { param ->
                    gmapsBridgeFactory(param[0])
                } bind IGMapsBridge::class
            },
        )
    )
}

For clarity’s sake, I’ve specified an explicit parameter name (param) to the definition lambda; you can always use the implicit it parameter.


Now, we want to open IOSGMapsBridge.swift. We need to add an InitialCameraState parameter to the init function, which we will use to initialize the Map’s camera like so:

IOSGMapsBridge.swift

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
init(withInitialCameraState state: InitialCameraState) {
    let options = GMSMapViewOptions()
    options.camera = state.toGMSCameraPosition()
    self._mapView = GMSMapView(options:options)
    super.init()
}

// ADD TO BOTTOM OF FILE...
extension InitialCameraState {
    /*
    Convert InitialCameraState to a GMSCameraPosition
    */
    func toGMSCameraPosition() -> GMSCameraPosition {
        return GMSCameraPosition(
            target: self.position.to2DCoord(),
        zoom: Float(self.zoomLevel)
        )
    }
}

Before we can run the app, we need to update the IGMapsBridge factory lambda to pass the injected InitialCameraState into the updated IOSGMapsBridge initializer:

iOSApp.swift

1
2
3
4
// Remember `initialCameraState` will be passed in from GMap.ios.kt
ComposeApp.PlatformDIKt.loadKoinSwiftModules { initialCameraState in 
        IOSGMapsBridge(withInitialCameraState: initialCameraState)
}

We’re almost there, I promise. Update App.kt to initialize GMap() with the initial location and zoom level. At the top of App.kt, add the following coordinate (Atomic Robot office) property:

App.kt

1
2
3
4
val arOfficeCoord = GMapCoordinate(
    latitude = 39.33328950909345,
    longitude = -84.31454170722023,
)

Next, at the top of the App() function, create a GMapState instance passing in the arOfficeCoord we just created. Then, pass the map state as the second argument of GMap().

App.kt

1
2
3
4
5
6
7
8
9
// Add this line at the top of the App() composable
val mapState = rememberGMapState(
    initialCameraState = InitialCameraState(position = arOfficeCoord),
)
// Replace previous call
GMap(
    mapState = mapState,
    modifier = Modifier.fillMaxSize(),
)

We’re almost ready. We just need to make a tiny change on iOS to allow our Map to fill the entire screen (behind the system bars). Open ContentView.swift and replace the modifier chain on ComposeView() with .ignoresSafeArea(.all).

Select the appropriate run configuration and hit run. You should see something that looks like this:

Android:

GMap Composable on Android simulator

iOS:

GMap Composable on iOS simulator

It’s not too shabby. The next feature we will tackle is displaying a Marker on the Map. We will start by displaying a Marker at the Atomic Robot office. By the end, we can display a marker anywhere you tap on the Map.

📍Markers📍

To add Marker support, we need a way to represent “marker data” from “common code.” Create a new type that will hold marker data. We will call this class GMapMarker. It will have three properties: title, snippet, and position. The position property is in geographical coordinates, as opposed to screen coordinates. When the marker is clicked/tapped, the map will display a popup containing the title and snippet values.

Next, we must add a “compose runtime” State property to hold a list of GMapMarker to GMapsState. The actual implementations of GMap() will subscribe to “markers” state and display them natively on the respective platform.

GMap.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class GMapMarker(
    val title: String,
    val snippet: String,
    val position: GMapCoordinate,
)

@Composable
fun rememberGMapState(
    initialCameraState: InitialCameraState = InitialCameraState(
        position = GMapCoordinate()
    ),
    // Initial map markers
    markers: List<GMapMarker> = emptyList(),
): GMapsState {
    val cameraState = createCameraPositionState(initialCameraState)
    return remember {
        GMapsState(
            initialCameraState = initialCameraState,
            cameraState = cameraState,
            markers = markers,
        )
    }
}

class GMapsState(
    val initialCameraState: InitialCameraState,
    val cameraState: GMapCameraPositionState,
    markers: List<GMapMarker> = emptyList(),
) {
    private val _markers = mutableStateOf(markers)
    val markers: State<List<GMapMarker>> = _markers

    val markerCount: State<Int>
        get() = derivedStateOf {
            _markers.value.size
        }

    fun addMarker(
        marker: GMapMarker,
    ) {
        _markers.value = _markers
            .value
            .toMutableList()
            .apply {
                add(marker)
            }
    }
}

Since the Android implementation will be straightforward, we’ll tackle it first. The Google Maps Compose library provides a composable, aptly called Marker(...), for displaying markers by calling it from the @Composable content lambda of GoogleMap(...). All that we need to do is collect the marker and call Marker(...).

The final code is short and to the point. Copy & paste the code below as a trailing lambda to the GoogleMap(...) composable function:

GMap.android.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
    val markers by mapState.markers
    markers.forEach { marker ->
        Marker(
            state = rememberMarkerState(
                position = marker.position.toLatLng()
            ),
            title = marker.title,
            snippet = marker.snippet
        )
    }
}

On iOS, we must update the IGMapsBridge interface and add a new function that accepts a list of GMapMarker.

IGMapsBridge.kt

1
2
3
4
5
/**
 * List of [GMapMarker] that should be displayed on the 
 * underlying GMSMapView.
 */
fun updateMapMarkers(markers: List<GMapMarker>)

For the actual implementation of GMap, we want to pass a lambda for the update parameter of UIKitView(...) that will update wrapped UIView when our marker state changes.

GMap.ios.kt

1
2
3
4
5
6
update = {
    // update {} is re-evaluated when markers state changes
    val markers by mapState.markers
    // Pass through to Swift land to handle native rendering
    mapBridge.updateMapMarkers(markers)
},

Next, open IOSGMapsBridge.swift and add the following code:

IOSGMapsBridge.swift

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var _markers: [GMSMarker] = []

/**
 * Clears the existing markers and attaches new ones to the map view.
 */
func updateMapMarkers(markers: [GMapMarker]) {
    // Clear existing marks
    self._markers.clear()

    // Add new marks and attach them to map
    markers.forEach { mrkr in
            let marker = GMSMarker()
        marker.position = mrkr.to2DCoord()
        marker.title = mrkr.title
        marker.snippet = mrkr.snippet
        marker.map = _mapView
        _markers.append(marker)
    }
}

// ADD TO BOTTOM OF FILE...
extension GMapCoordinate {
    func to2DCoord() -> CLLocationCoordinate2D {
        return CLLocationCoordinate2D(
            latitude: CLLocationDegrees(self.latitude),
            longitude: CLLocationDegrees(self.longitude)
        )
    }
}

The code is simple enough. The one non-obvious part is the need for the markers array. For whatever reason, GMSMapView doesn’t allow us to access the markers, and GMSMapView.clear() removes all overlays (includes).

We need to update our call to rememberGMapState(...) to pass in our initial marker above the Atomic Robot office:

App.kt

1
2
3
4
val mapState = rememberGMapState(
    initialCameraState = InitialCameraState(position = arOfficeCoord),
    markers = listOf(arOfficeMarker),
)

We’re ready to see a marker 📍 on our Map. Hit the run button, and you should see results that look like this:

Android:

GMap w/ custom Marker Composable on Android emulator

iOS:

GMap w/ custom Marker Composable on iOS simulator

Touchy feely

Let’s add interactive functionality to the Map and detect taps and long presses. On both platforms, the native map “view” natively supports detecting taps and presses and reporting the appropriate Lat/Long coordinates for the gesture. We need to hook into this functionality and convert the native coordinate objects into GMapCoordinate to use them in “common code.”


Let’s create a new typealias for two new functional types we’ll use for listening gestures on the map. Their names will be GMapTapBridge and GMapLongPressBridge, respectively.

Gmap.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/**
 * Invoked when the [GMap] is tapped.
 * @param [GMapCoordinate] underneath of where tap gesture
 */
typealias GMapTapBridge = (coord: GMapCoordinate) -> Unit
/**
 * Invoked when the [GMap] is long pressed.
 * @param [GMapCoordinate] underneath long press gesture
 */
typealias GMapLongPressBridge = (coord: GMapCoordinate) -> Unit

Update the signature of GMap(...) and add the new gesture listeners as parameters.

Gmap.kt

1
2
3
4
5
6
7
@Composable
expect fun GMap(
    mapState: GMapsState = rememberGMapState(),
    modifier: Modifier = Modifier,
    onMapClick: GMapTapBridge? = null,
    onMapLongClick: GMapLongPressBridge? = null,
)

Before we move on, we should finally be ready to add support for animating the Map’s camera. We need to add a sealed class called GMapCameraUpdate that we will use to abstractly represent camera updates. Add a new suspending function to GMapCameraPositionState that will accept a GMapCameraUpdate.

Gmap.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/**
 * Platform-agnostic representation of a camera update (animation).
 */
sealed interface GMapCameraUpdate {
    /**
     * Camera update that will center the map camera's viewport on
      [position].
     * @param position geographic coordinates of the Map's camera
     * @param zoom
     */
    data class PositionUpdate(
        val position: GMapCoordinate,
        val zoom: Double = 14.0,
    ) : GMapCameraUpdate
}

expect class GMapCameraPositionState {
    suspend fun animateCamera(update: GMapCameraUpdate)
}

Add the following function to GMapState that will pass through camera updates to the underlying GMapCameraPositionState:

Gmap.kt

1
2
3
4
// Add to GMapState...
suspend fun animateCamera(
    update: GMapCameraUpdate,
) = cameraState.animateCamera(update)

(This is just a convenience and is not explicitly necessary)

For the Android implementation of GMapCameraPositionState.animateCamera, we want to first convert the GMapCameraUpdate to a CameraUpdate and pass it to CameraPositionState. Add the new gesture listeners parameters toGMap(...). They will called from the native gesture listeners of GoogleMaps(...). Here is my code for reference:

Gmap.android.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
actual class GMapCameraPositionState(
    val cameraPositionState: CameraPositionState
) {
    actual suspend fun animateCamera(update: GMapCameraUpdate) {
        when (update) {
            is GMapCameraUpdate.PositionUpdate -> 
            cameraPositionState.animate(
                update = update.toCameraUpdate()
            )
        }
    }
}

@Composable
actual fun GMap(
    mapState: GMapsState = rememberGMapState(),
    modifier: Modifier = Modifier,
    onMapClick: GMapTapBridge? = null,
    onMapLongClick: GMapLongPressBridge? = null,
) {
    GoogleMap(
        modifier = modifier,
        cameraPositionState = mapState.cameraState.cameraPositionState,
        uiSettings = mapSettings.toUISettings(),
        onMapClick = onMapClick?.let {
            { it(it.toGMapCoordinate()) }
        },
        onMapLongClick = onMapLongClick?.let {
            { it(it.toGMapCoordinate()) }
        }
    ) {
        val markers by mapState.markers
        markers.forEach { marker ->
            Marker(
                state = rememberMarkerState(
                    position = marker.position.toLatLng()
                ),
                title = marker.title,
                snippet = marker.snippet
            )
        }
    }
}

/**
 * Convert [GMapCameraUpdate.PositionUpdate] to [CameraUpdate].
 */
private fun GMapCameraUpdate.PositionUpdate.toCameraUpdate(): CameraUpdate =
    CameraUpdateFactory.newLatLngZoom(this.toLatLng(), this.zoom.toFloat())

/**
 * Convert a [LatLng] to a [GMapCoordinate].
 */
private fun LatLng.toGMapCoordinate(): GMapCoordinate = GMapCoordinate(
    latitude = this.latitude,
    longitude = this.longitude,
)

Inside the IGMapsBridge interface, we want to add properties for the new gesture listeners to observe GMSMapView gestures from our common code.

IGMapsBridge.kt

1
2
3
4
5
6
7
8
9
var onTapped: GMapTapBridge?

var onLongPressed: GMapLongPressBridge?

/**
 * Animates the GMSMapView's camera to the position/zoom level represented
 * by [GMapCameraUpdate].
 */
fun animateCamera(position: GMapCameraUpdate)

We need a way to define an indefinite stream of “updates” that we can observe from Compose and pass along to IGMapsBridge which will manually update the Map’s camera. Each “update” is considered a one-off event, so we don’t need to hold the previous or current value. As soon as an update is “emitted,” any available “collectors” will handle it. Additional requirement: we want to maintain the suspending/synchronous contract to guarantee order if rapid updates are submitted. This is a perfect use case for Flows, which, by default, provides an asynchronous stream of data with no replay or caching. Flows also offers a suspending API that guarantees the ordering of data.  

For the iOS actual implementation of GMapCameraPositionState, we will use a SharedFlow since we only need a single stream of camera updates tied to a given GMapCameraPositionState instance.

Gmap.ios.kt

1
2
3
4
5
6
7
8
9
actual class GMapCameraPositionState {
    private val _cameraUpdate: MutableSharedFlow<GMapCameraUpdate> =
        MutableSharedFlow<GMapCameraUpdate>()
    val cameraUpdate: Flow<GMapCameraUpdate> = _cameraUpdate.asSharedFlow()

    actual suspend fun animateCamera(update: GMapCameraUpdate) {
        _cameraUpdate.emit(update)
    }
}

To detect gestures on the underlying GMSMapView, we must implement the GMSMapViewDelegate protocol, override the mapView:didTapAt: and mapView:didLongPressAt: callbacks, and call through to the custom gesture listener functions we declared above.

Integrate the following code into IOSGMapsBridge.swift (replace init(…)):

IOSGMapsBridge.swift

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var onLongPressed: ((GMapCoordinate) -> Void)?
    
var onTapped: ((GMapCoordinate) -> Void)?

init(withInitialCameraState state: InitialCameraState) {
    let options = GMSMapViewOptions()
    options.camera = state.toGMSCameraPosition()
    self._mapView = GMSMapView(options:options)
    super.init()
    // We have to call super.init() before we can use 'self'...
    self._mapView.delegate = self
}

func mapView(
    _ mapView: GMSMapView,
     didTapAt coordinate: CLLocationCoordinate2D
) {
    // call the custom tap listener 
    self.onTapped?(coordinate.toGMapCoordinate())
}

func mapView(
    _ mapView: GMSMapView,
     didLongPressAt coordinate: CLLocationCoordinate2D
) {
    // call the custom long press listener
    self.onLongPressed?(coordinate.toGMapCoordinate())
}

func animateCamera(position: GMapCameraUpdate) {
    switch(position) {
        case let position as GMapCameraUpdatePositionUpdate:
        self._mapView.animate(with: position.toGMSCameraUpdate())
        default: ()
    }
}

// ADD TO BOTTOM OF FILE...
extension GMapCameraUpdatePositionUpdate {
    func toGMSCameraUpdate() -> GMSCameraUpdate {
        return GMSCameraUpdate.setTarget(
            self.position.to2DCoord(),
            zoom: Float(self.zoom)
        )
    }
}

The initializer may look identical to our previous update, but it has one crucial difference: We assign self as the Map’s delegate. This will wire up the two callbacks we implemented.

For the actual implementation of GMap(), we need to add the new gesture listeners parameters as we did for Android. Then, we will assign listeners to the new properties we added to the IGMapsBridge interface.

Replacing val map = remember { mapBridge.mapView() } with the following code:

Gmap.ios.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
val map = remember {
    // Register tap and Long press callbacks...
    mapBridge.onTapped = onMapClick
    mapBridge.onLongPressed = onMapLongClick
    mapBridge.mapView()
}

// Observe the camera update state
LaunchedEffect(Unit) {
    withContext(Dispatchers.Main) {
        mapState.cameraState.cameraUpdate
            .onEach(mapBridge::animateCamera)
            .launchIn(this)
    }
}

The LaunchedEffect(Unit) above will provide us with an auto-canceled CoroutineScope that we use to collect the camera update flow.


We’re nearly ready to test our most recent round of changes. We just need to update App() to provide a listener for the tap and long press gestures.

For our simple app, we want to display the lat/long coordinates of the area where the user tapped. We want to place a marker when a user long presses on the map. Replace the current GMap usage with the update code below:

App.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var clickedLocation by remember { mutableStateOf(null as GMapCoordinate?) }
Box {
    GMap(
        mapState = mapState,
        modifier = Modifier.fillMaxSize(),
        onMapClick = { clickedLocation = it },
        onMapLongClick = {
            // Place a marker wherever the user long presses on the Map
            val count = mapState.markerCount.value + 1
            mapState.addMarker(
                GMapMarker(
                    title = "User Marker #${count}",
                    snippet = "User added marker #${count}",
                    position = it,
                )
            )
        },
    )

    val coroutineScope = rememberCoroutineScope()
    CoordinateCard(
        coordinate = clickedLocation,
        modifier = Modifier
            .align(Alignment.TopCenter)
            .statusBarsPadding()
            .padding(horizontal = 16.dp),
        onDismiss = { clickedLocation = null },
        onNavigateClicked = {
            // Animate the Map to the selected location
            coroutineScope.launch { 
                mapState.animateCamera(it.toCameraUpdate()) 
            }
        },
    )
}

Here’s the code for CoordinateCard(). Note: I omit an explanation of what this composable does and leave it as an exercise for the reader. Though, it’s not doing anything special.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Composable
private fun CoordinateCard(
    coordinate: GMapCoordinate?,
    modifier: Modifier = Modifier,
    onDismiss: () -> Unit,
    onNavigateClicked: (GMapCoordinate) -> Unit,
) {
    val ref = remember { Ref<GMapCoordinate>() }
        .apply { this.value = coordinate ?: this.value }
    AnimatedVisibility(
        visible = coordinate != null,
        modifier = modifier
    ) {
        ref.value?.let { value ->
            Card(elevation = 6.dp) {
                Column(Modifier.padding(16.dp)) {
                    IconButton(
                        onClick = onDismiss,
                        modifier = Modifier.align(Alignment.End),
                        content = {
                            Icon(
                                imageVector = Icons.Default.Close,
                                contentDescription = "Close"
                            )
                        }
                    )
                    Column(
                        verticalArrangement = Arrangement.spacedBy(8.dp),
                        modifier = Modifier.fillMaxWidth(),
                        content = {
                            CompositionLocalProvider(
                                LocalTextStyle provides MaterialTheme
                                .typography
                                .body1,
                            ) {
                                Text(text = "Lat: ${value.latitude}")
                                Text(text = "Long: ${value.longitude}")
                            }
                        }
                    )

                    Button(
                        onClick = { onNavigateClicked(value) },
                        modifier = Modifier.fillMaxWidth(),
                        content = { Text(text = "Center On") }
                    )
                }
            }
        }
    }
}

We're ready to demo our changes. You should see something that looks and behaves like the following:

Android:

GMap touch gestures on Android emulator

iOS:

GMap Composable on iPhone simulator

Conclusion

We’ve extended the simple GMap(...) composable I left you with in my first post, and now we have a multiplatform Map that can display custom markers, animate the camera, and convert taps to Lat/Long coordinates. While we stopped here, there is still room for improvements such as dragging markers, marker customization (basic & advanced), adding shapes (circles, polygons, and lines), and polylines (think routes). I will leave the rest for the curious readers as an exercise in your determination and ingenuity. For those who would rather have a professional and carefully thought-out solution, reach out to us at Atomic Robot – we’d be happy to help.

Copyright © 2025 JetBrains s.r.o. Kotlin Multiplatform and Compose Multiplatform and the Kotlin Multiplatform and Compose Multiplatform logos are trademarks of JetBrains s.r.o. www.jetbrains.com

Copyright © 2025 Google LLC. Jetpack Compose, Android, and Google Maps logos are trademarks of Google LLC. www.google.com