Compose Multiplatform: A Practically-Native Example: part 2!
Enhancing our Google Maps multiplatform Composable
Jonathan Davis
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:
/**
* Generic representation for geographic coordinates
*/dataclassGMapCoordinate(
val latitude: Double = 0.00,
val longitude: Double = 0.00,
)
**
* Location and zoom level the Map will load at start
*/
classInitialCameraState(
val position: GMapCoordinate,
val zoomLevel: Double = 14.0,
)
/**
* Abstraction to interact with the map camera.
*/expectclassGMapCameraPositionStateclassGMapsState(
val initialCameraState: InitialCameraState,
val cameraState: GMapCameraPositionState,
)
@ComposableexpectfuncreateCameraPositionState(
initialCameraState: InitialCameraState,
): GMapCameraPositionState
@ComposablefunrememberGMapState(
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:
actualclassGMapCameraPositionState(
val cameraPositionState: CameraPositionState
)
@ComposableactualfuncreateCameraPositionState(
initialCameraState: InitialCameraState,
): GMapCameraPositionState = GMapCameraPositionState(
cameraPositionState = rememberCameraPositionState {
position = initialCameraState.toCameraPosition()
}
)
@ComposableactualfunGMap(
mapState: GMapsState,
modifier: Modifier,
) {
GoogleMap(
modifier = modifier,
cameraPositionState = mapState.cameraState.cameraPositionState,
)
}
/**
* Convert a [GMapCoordinate] to a [LatLng] (native representation of
* geographic coordinates).
*/privatefunGMapCoordinate.toLatLng() = LatLng(
/* latitude = */this.latitude,
/* longitude = */this.longitude,
)
/**
* Convert a [InitialCameraState] to a [CameraPosition] (sets map
* camera position).
*/privatefunInitialCameraState.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:
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:
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
funloadKoinSwiftModules(
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:
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.ktComposeApp.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:
iOS:
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.
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.
*/funupdateMapMarkers(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:
var_markers: [GMSMarker] = []
/**
* Clears the existing markers and attaches new ones to the map view.
*/funcupdateMapMarkers(markers: [GMapMarker]) {
// Clear existing marksself._markers.clear()
// Add new marks and attach them to map markers.forEach { mrkr inletmarker = GMSMarker()
marker.position = mrkr.to2DCoord()
marker.title = mrkr.title
marker.snippet = mrkr.snippet
marker.map = _mapView
_markers.append(marker)
}
}
// ADD TO BOTTOM OF FILE...extensionGMapCoordinate {
functo2DCoord() -> 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:
We’re ready to see a marker 📍 on our Map. Hit the run button, and you should see results that look like this:
Android:
iOS:
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.
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).
*/sealedinterfaceGMapCameraUpdate {
/**
* Camera update that will center the map camera's viewport on
[position].
* @param position geographic coordinates of the Map's camera
* @param zoom
*/dataclassPositionUpdate(
val position: GMapCoordinate,
val zoom: Double = 14.0,
) : GMapCameraUpdate
}
expectclassGMapCameraPositionState {
suspendfunanimateCamera(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...
suspendfunanimateCamera(
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:
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].
*/funanimateCamera(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.
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(…)):
varonLongPressed: ((GMapCoordinate) -> Void)?
varonTapped: ((GMapCoordinate) -> Void)?
init(withInitialCameraState state: InitialCameraState) {
letoptions = 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}
funcmapView(
_ mapView: GMSMapView,
didTapAt coordinate: CLLocationCoordinate2D
) {
// call the custom tap listener self.onTapped?(coordinate.toGMapCoordinate())
}
funcmapView(
_ mapView: GMSMapView,
didLongPressAt coordinate: CLLocationCoordinate2D
) {
// call the custom long press listenerself.onLongPressed?(coordinate.toGMapCoordinate())
}
funcanimateCamera(position: GMapCameraUpdate) {
switch(position) {
caseletpositionas GMapCameraUpdatePositionUpdate:
self._mapView.animate(with: position.toGMSCameraUpdate())
default: ()
}
}
// ADD TO BOTTOM OF FILE...extensionGMapCameraUpdatePositionUpdate {
functoGMSCameraUpdate() -> 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:
var clickedLocation by remember { mutableStateOf(nullas 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.
We're ready to demo our changes. You should see something that looks and behaves like the following:
Android:
iOS:
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.