diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 146e0c53..c3b5aa63 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,13 +43,13 @@ android:name=".BasicMapActivity" android:exported="false" /> + + + + + diff --git a/app/src/main/java/com/google/maps/android/compose/BasicMapActivity.kt b/app/src/main/java/com/google/maps/android/compose/BasicMapActivity.kt index 79cb11c5..47eb2515 100644 --- a/app/src/main/java/com/google/maps/android/compose/BasicMapActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/BasicMapActivity.kt @@ -141,7 +141,7 @@ fun GoogleMapView( val singapore4State = rememberMarkerState(position = singapore4) var circleCenter by remember { mutableStateOf(singapore) } - if (singaporeState.dragState == DragState.END) { + if (!singaporeState.isDragging) { circleCenter = singaporeState.position } @@ -380,7 +380,7 @@ private fun DebugView( Text(text = "Camera position is ${cameraPositionState.position}") Spacer(modifier = Modifier.height(4.dp)) val dragging = - if (markerState.dragState == DragState.DRAG) "dragging" else "not dragging" + if (markerState.isDragging) "dragging" else "not dragging" Text(text = "Marker is $dragging") Text(text = "Marker position is ${markerState.position}") } diff --git a/app/src/main/java/com/google/maps/android/compose/MainActivity.kt b/app/src/main/java/com/google/maps/android/compose/MainActivity.kt index f7fadb44..42ef0c0e 100644 --- a/app/src/main/java/com/google/maps/android/compose/MainActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/MainActivity.kt @@ -32,6 +32,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import com.google.maps.android.compose.markerexamples.AdvancedMarkersActivity +import com.google.maps.android.compose.markerexamples.MarkerClusteringActivity +import com.google.maps.android.compose.markerexamples.draggablemarkerscollectionwithpolygon.DraggableMarkersCollectionWithPolygonActivity +import com.google.maps.android.compose.markerexamples.markerdragevents.MarkerDragEventsActivity +import com.google.maps.android.compose.markerexamples.markerscollection.MarkersCollectionActivity +import com.google.maps.android.compose.markerexamples.syncingdraggablemarkerwithdatamodel.SyncingDraggableMarkerWithDataModelActivity +import com.google.maps.android.compose.markerexamples.updatingnodragmarkerwithdatamodel.UpdatingNoDragMarkerWithDataModelActivity import com.google.maps.android.compose.theme.MapsComposeSampleTheme class MainActivity : ComponentActivity() { @@ -141,6 +148,41 @@ class MainActivity : ComponentActivity() { }) { Text(getString(R.string.recomposition_activity)) } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, MarkerDragEventsActivity::class.java)) + }) { + Text(getString(R.string.marker_drag_events_activity)) + } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, MarkersCollectionActivity::class.java)) + }) { + Text(getString(R.string.markers_collection_activity)) + } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, SyncingDraggableMarkerWithDataModelActivity::class.java)) + }) { + Text(getString(R.string.syncing_draggable_marker_with_data_model)) + } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, UpdatingNoDragMarkerWithDataModelActivity::class.java)) + }) { + Text(getString(R.string.updating_non_draggable_marker_with_data_model)) + } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, DraggableMarkersCollectionWithPolygonActivity::class.java)) + }) { + Text(getString(R.string.draggable_markers_collection_with_polygon)) + } } } } diff --git a/app/src/main/java/com/google/maps/android/compose/AdvancedMarkersActivity.kt b/app/src/main/java/com/google/maps/android/compose/markerexamples/AdvancedMarkersActivity.kt similarity index 94% rename from app/src/main/java/com/google/maps/android/compose/AdvancedMarkersActivity.kt rename to app/src/main/java/com/google/maps/android/compose/markerexamples/AdvancedMarkersActivity.kt index 48cf92e8..e32b08a1 100644 --- a/app/src/main/java/com/google/maps/android/compose/AdvancedMarkersActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/markerexamples/AdvancedMarkersActivity.kt @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package com.google.maps.android.compose +package com.google.maps.android.compose.markerexamples import android.R.drawable.ic_menu_myplaces @@ -36,6 +36,12 @@ import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.PinConfig +import com.google.maps.android.compose.AdvancedMarker +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapProperties +import com.google.maps.android.compose.MapType +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberMarkerState private const val TAG = "AdvancedMarkersActivity" diff --git a/app/src/main/java/com/google/maps/android/compose/MarkerClusteringActivity.kt b/app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt similarity index 95% rename from app/src/main/java/com/google/maps/android/compose/MarkerClusteringActivity.kt rename to app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt index 3f025e62..8301b006 100644 --- a/app/src/main/java/com/google/maps/android/compose/MarkerClusteringActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt @@ -1,4 +1,4 @@ -package com.google.maps.android.compose +package com.google.maps.android.compose.markerexamples import android.os.Bundle import android.util.Log @@ -40,9 +40,16 @@ import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.MarkerInfoWindow import com.google.maps.android.compose.clustering.Clustering import com.google.maps.android.compose.clustering.rememberClusterManager import com.google.maps.android.compose.clustering.rememberClusterRenderer +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.singapore +import com.google.maps.android.compose.singapore2 import kotlin.random.Random private val TAG = MarkerClusteringActivity::class.simpleName diff --git a/app/src/main/java/com/google/maps/android/compose/markerexamples/draggablemarkerscollectionwithpolygon/DraggableMarkersCollectionWithPolygonActivity.kt b/app/src/main/java/com/google/maps/android/compose/markerexamples/draggablemarkerscollectionwithpolygon/DraggableMarkersCollectionWithPolygonActivity.kt new file mode 100644 index 00000000..8bea519c --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/markerexamples/draggablemarkerscollectionwithpolygon/DraggableMarkersCollectionWithPolygonActivity.kt @@ -0,0 +1,192 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose.markerexamples.draggablemarkerscollectionwithpolygon + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Modifier +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.Polygon +import com.google.maps.android.compose.defaultCameraPosition +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.theme.MapsComposeSampleTheme + +/** + * Data type representing a location. + * + * This only stores location position, for illustration, + * but could hold other data related to the location. + */ +@Immutable +private data class LocationData(val position: LatLng) + +/** + * Unique, stable key for location + */ +private class LocationKey + +/** + * Encapsulates mapping from data model to MarkerStates. Part of view model. + * MarkerStates are relegated to an implementation detail. + * Use new [DraggableMarkersModel] instance if data model is updated externally: + * MarkerStates are source of truth after initialization from data model. + */ +@Stable +private class DraggableMarkersModel(dataModel: Map) { + // This initializes MarkerState from our model once (model is initial source of truth) + // and never updates it from the model afterwards. + // See SyncingDraggableMarkerWithDataModelActivity for rationale. + private val markerDataMap: SnapshotStateMap = mutableStateMapOf( + *dataModel.entries.map { (locationKey, locationData) -> + locationKey to MarkerState(locationData.position) + }.toTypedArray() + ) + + /** Add new marker location to model */ + fun addLocation(locationData: LocationData) { + markerDataMap += LocationKey() to MarkerState(locationData.position) + } + + /** Delete marker location from model */ + private fun deleteLocation(locationKey: LocationKey) { + markerDataMap -= locationKey + } + + /** + * Render Markers from model + */ + @Composable + fun Markers() = markerDataMap.forEach { (locationKey, markerState) -> + key(locationKey) { + LocationMarker( + markerState, + onClick = { deleteLocation(locationKey) } + ) + } + } + + /** + * List of functions providing current positions of Markers. + * + * Calling from composition will trigger recomposition when Markers and their positions + * change. + */ + val markerPositionsModel: List<() -> LatLng> + get() = markerDataMap.values.map { { it.position } } +} + +/** + * Demonstrates how to sync a data model with a changing collection of + * draggable markers using keys, while keeping a Polygon of the marker positions in sync with + * the current marker position. + * + * The user can add a location marker to the model by clicking the map and delete a location from + * the model by clicking a marker. + * + * This example builds on top of ideas from MarkersCollectionActivity and + * SyncingDraggableMarkerWithDataModelActivity. + */ +class DraggableMarkersCollectionWithPolygonActivity : ComponentActivity() { + // Simplistic data model from repository being set from outside (should be part of view model); + // Only stores [LocationData], for illustration, but could hold additional data. + private var dataModel: Map = mapOf() + set(value) { + field = value + markersModel = DraggableMarkersModel(value) + } + + private var markersModel by mutableStateOf(DraggableMarkersModel(dataModel)) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MapsComposeSampleTheme { + GoogleMapWithLocations( + markersModel, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +/** + * A GoogleMap with locations represented by markers + */ +@Composable +private fun GoogleMapWithLocations( + markersModel: DraggableMarkersModel, + modifier: Modifier = Modifier +) { + val cameraPositionState = rememberCameraPositionState { position = defaultCameraPosition } + + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState, + onMapClick = { position -> markersModel.addLocation(LocationData(position)) } + ) { + markersModel.Markers() + + Polygon(markersModel::markerPositionsModel) + } +} + +/** + * A draggable GoogleMap Marker representing a location on the map + */ +@Composable +private fun LocationMarker( + markerState: MarkerState, + onClick: () -> Unit +) { + Marker( + state = markerState, + draggable = true, + onClick = { + onClick() + + true + } + ) +} + +/** + * A Polygon. Helps isolate recompositions while a Marker is being dragged. + */ +@Composable +private fun Polygon(markerPositionsModel: () -> List<() -> LatLng>) { + val movingMarkerPositions = markerPositionsModel() + + if (movingMarkerPositions.isNotEmpty()) { + val markerPositions = movingMarkerPositions.map { it() } + + Polygon(markerPositions) + } +} diff --git a/app/src/main/java/com/google/maps/android/compose/markerexamples/markerdragevents/MarkerDragEventsActivity.kt b/app/src/main/java/com/google/maps/android/compose/markerexamples/markerdragevents/MarkerDragEventsActivity.kt new file mode 100644 index 00000000..5862ce60 --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/markerexamples/markerdragevents/MarkerDragEventsActivity.kt @@ -0,0 +1,127 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose.markerexamples.markerdragevents + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.defaultCameraPosition +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.singapore +import com.google.maps.android.compose.theme.MapsComposeSampleTheme +import kotlinx.coroutines.flow.dropWhile + +private val TAG = MarkerDragEventsActivity::class.simpleName + +/** + * Demonstrates how to reliably generate a sequence of Marker drag START-DRAG-END events as in the + * original GoogleMap Marker listener. + */ +class MarkerDragEventsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MapsComposeSampleTheme { + GoogleMapWithMarker( + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun GoogleMapWithMarker( + modifier: Modifier = Modifier, +) { + val cameraPositionState = rememberCameraPositionState { position = defaultCameraPosition } + + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState, + ) { + DraggableMarker( + onDragStart = { Log.i(TAG, "onDragStart") }, + onDragEnd = { Log.i(TAG, "onDragEnd") }, + onDrag = { position -> Log.i(TAG, "onDrag: $position") } + ) + } +} + +/** + * A draggable GoogleMap Marker. + * + * @param onDragStart called when marker dragging starts + * @param onDrag called with an update for the marker's current position during dragging + * @param onDragEnd called when marker dragging ends + */ +@Composable +private fun DraggableMarker( + onDragStart: () -> Unit = {}, + onDrag: (LatLng) -> Unit = {}, + onDragEnd: () -> Unit = {} +) { + val markerState = rememberMarkerState(position = singapore) + + Marker( + state = markerState, + draggable = true + ) + + LaunchedEffect(Unit) { + var inDrag = false + var priorPosition: LatLng? = singapore + + snapshotFlow { markerState.isDragging to markerState.position } + .dropWhile { (isDragging, position) -> + !isDragging && position == priorPosition // ignore initial value + } + .collect { (isDragging, position) -> + // Do not even bother to check isDragging state here: + // it is possible to miss a sequence of states + // where isDragging == true, then isDragging == false; + // in this case we would only see a change in position. + // (Hypothetically we could even miss a change in position + // if the Marker ended up in its original position at the + // end of the drag. But then nothing changed at all, + // so we should be ok to ignore this case altogether.) + if (!inDrag) { + inDrag = true + onDragStart() + } + + if (position != priorPosition) { + onDrag(position) + priorPosition = position + } + + if (!isDragging) { + inDrag = false + onDragEnd() + } + } + } +} diff --git a/app/src/main/java/com/google/maps/android/compose/markerexamples/markerscollection/MarkersCollectionActivity.kt b/app/src/main/java/com/google/maps/android/compose/markerexamples/markerscollection/MarkersCollectionActivity.kt new file mode 100644 index 00000000..dacb43ac --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/markerexamples/markerscollection/MarkersCollectionActivity.kt @@ -0,0 +1,154 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose.markerexamples.markerscollection + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.Modifier +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.defaultCameraPosition +import com.google.maps.android.compose.markerexamples.updatingnodragmarkerwithdatamodel.Marker +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.theme.MapsComposeSampleTheme + +/** + * Simplistic app data model intended for persistent storage. + * + * This only stores [LocationData], for demonstration purposes, but could hold an entire app's data. + */ +private class DataModel { + /** + * Location data. + */ + val locationDataMap = mutableStateMapOf() +} + +/** + * Data type representing a location. + * + * This only stores location position, for demonstration purposes, + * but could hold other data related to the location. + */ +@Immutable +private data class LocationData(val position: LatLng) + +/** + * Unique, stable key for location + */ +private class LocationKey + +private typealias KeyedLocationData = Pair + +/** + * Demonstrates how to sync a data model with a changing collection of + * location markers using keys. + * + * The user can add a location marker to the model by clicking the map and delete a location from + * the model by clicking a marker. + * + * This example reuses the simple non-draggable Marker approach from the + * `UpdatingNoDragMarkerWithDataModelActivity` example, which encapsulates + * [MarkerState] to provide a cleaner API surface. + */ +class MarkersCollectionActivity : ComponentActivity() { + private val dataModel = DataModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MapsComposeSampleTheme { + Screen( + dataModel = dataModel, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun Screen( + dataModel: DataModel, + modifier: Modifier = Modifier +) = GoogleMapWithLocations( + modifier = modifier, + keyedLocationData = dataModel.locationDataMap.toList(), + onAddLocation = { locationData -> + dataModel.locationDataMap += LocationKey() to locationData + }, + onDeleteLocation = { key -> + dataModel.locationDataMap -= key + } +) + +/** + * A GoogleMap with locations represented by markers + * + * @param keyedLocationData model data for location markers with unique keys. + * Uses a [Collection] type to keep it independent of our data model. + * @param onAddLocation location addition events for updating data model + * @param onDeleteLocation location deletion events for updating data model + */ +@Composable +private fun GoogleMapWithLocations( + keyedLocationData: Collection, + modifier: Modifier = Modifier, + onAddLocation: (LocationData) -> Unit, + onDeleteLocation: (LocationKey) -> Unit +) { + val cameraPositionState = rememberCameraPositionState { position = defaultCameraPosition } + + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState, + onMapClick = { position -> onAddLocation(LocationData(position)) } + ) { + Locations( + keyedLocationData = keyedLocationData, + onLocationClick = onDeleteLocation + ) + } +} + +/** + * Renders locations on a GoogleMap + * + * @param keyedLocationData model data for location markers with unique keys. + * @param onLocationClick location click events + */ +@Composable +private fun Locations( + keyedLocationData: Collection, + onLocationClick: (LocationKey) -> Unit +) = keyedLocationData.forEach { (key, locationData) -> + key(key) { + Marker( + position = locationData.position, + onClick = { + onLocationClick(key) + true // consume click event to prevent camera move to marker + } + ) + } +} diff --git a/app/src/main/java/com/google/maps/android/compose/markerexamples/syncingdraggablemarkerwithdatamodel/SyncingDraggableMarkerWithDataModelActivity.kt b/app/src/main/java/com/google/maps/android/compose/markerexamples/syncingdraggablemarkerwithdatamodel/SyncingDraggableMarkerWithDataModelActivity.kt new file mode 100644 index 00000000..1aa48ae9 --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/markerexamples/syncingdraggablemarkerwithdatamodel/SyncingDraggableMarkerWithDataModelActivity.kt @@ -0,0 +1,168 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose.markerexamples.syncingdraggablemarkerwithdatamodel + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.defaultCameraPosition +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.singapore +import com.google.maps.android.compose.theme.MapsComposeSampleTheme + +/** + * Simplistic app data model intended for persistent storage. + * + * This only stores [LocationData], for demonstration purposes, but could hold an entire app's data. + */ +private class DataModel { + /** + * Location data + */ + var locationData by mutableStateOf(LocationData(singapore)) +} + +/** + * Data type representing a location. + * + * This only stores location position, for demonstration purposes, + * but could hold other data related to the location. + */ +@Immutable +private data class LocationData(val position: LatLng) + +/** + * Demonstrates how to avoid data races when keeping a data model in sync + * with location derived from a draggable marker. The model is the initial source of truth for the + * marker's position; markers are draggable, so MarkerState becomes the source of truth after + * initialization. + * + * This addresses difficulties caused by having source of truth for position baked into + * com.google.android.gms.maps.model.Marker, and consequently MarkerState. + */ +class SyncingDraggableMarkerWithDataModelActivity : ComponentActivity() { + private val dataModel = DataModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MapsComposeSampleTheme { + Screen( + dataModel = dataModel, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun Screen( + dataModel: DataModel, + modifier: Modifier = Modifier +) { + GoogleMapWithLocation( + modifier = modifier, + locationData = dataModel.locationData, + onUpdateLocation = { locationData -> + dataModel.locationData = locationData + } + ) +} + +/** + * A GoogleMap with a location represented by a marker + * + * @param locationData model data for location marker. The UI becomes the source of truth for + * marker position after initial composition; the model's position is ignored on recomposition. + * @param onUpdateLocation location update events for updating data model + */ +@Composable +private fun GoogleMapWithLocation( + locationData: LocationData, + modifier: Modifier = Modifier, + onUpdateLocation: (LocationData) -> Unit +) { + val cameraPositionState = rememberCameraPositionState { position = defaultCameraPosition } + + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState, + ) { + LocationMarker( + locationData = locationData, + onLocationUpdate = onUpdateLocation + ) + } +} + +/** + * A draggable GoogleMap Marker representing a location on the map. + * + * @param locationData model data for location marker. The UI becomes the source of truth for + * marker position after initial composition; the model's position is ignored on recomposition. + * @param onLocationUpdate marker update events with updated [LocationData] + */ +@Composable +private fun LocationMarker( + locationData: LocationData, + onLocationUpdate: (LocationData) -> Unit +) { + // This sets the MarkerData from our model once (model is initial source of truth) + // and never updates it from the model afterwards, + // because MarkerState/GoogleMap is the source of truth after initialization + // and we want to avoid multiple competing sources of truth + // to prevent potential data races. + // This achieves a clean separation of sources of truth at the cost of + // no longer having state flow down. + // It is the price we pay for having source of truth baked into + // com.google.android.gms.maps.model.Marker, and consequently MarkerState. + // + // Do not use rememberMarkerState() here, because it uses rememberSaveable(); + // we want to save the position to persistent storage as part of our data model + // instead - rememberSaveable() would add a conflicting source of truth. + val markerState = remember { MarkerState(locationData.position) } + + Marker( + state = markerState, + draggable = true + ) + + LaunchedEffect(Unit) { + snapshotFlow { markerState.position } + .collect { position -> + // build LocationData update from marker update + val update = LocationData(position = position) + + // send update event + onLocationUpdate(update) + } + } +} diff --git a/app/src/main/java/com/google/maps/android/compose/markerexamples/updatingnodragmarkerwithdatamodel/UpdatingNoDragMarkerWithDataModelActivity.kt b/app/src/main/java/com/google/maps/android/compose/markerexamples/updatingnodragmarkerwithdatamodel/UpdatingNoDragMarkerWithDataModelActivity.kt new file mode 100644 index 00000000..ecd58208 --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/markerexamples/updatingnodragmarkerwithdatamodel/UpdatingNoDragMarkerWithDataModelActivity.kt @@ -0,0 +1,154 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose.markerexamples.updatingnodragmarkerwithdatamodel + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.defaultCameraPosition +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.singapore +import com.google.maps.android.compose.singapore2 +import com.google.maps.android.compose.singapore3 +import com.google.maps.android.compose.theme.MapsComposeSampleTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * Simplistic app data model intended for persistent storage. + * + * This only stores [LocationData], for demonstration purposes, but could hold an entire app's data. + */ +private class DataModel { + /** + * Location data + */ + var locationData by mutableStateOf(LocationData(singapore)) +} + +/** + * Data type representing a location. + * + * This only stores location position, for demonstration purposes, + * but could hold other data related to the location. + */ +@Immutable +private data class LocationData(val position: LatLng) + +/** + * Demonstrates how to easily initialize and update position for a non-draggable + * Marker from a data model. + */ +class UpdatingNoDragMarkerWithDataModelActivity : ComponentActivity() { + private val dataModel = DataModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + // Simulate remote updates to data model + while (true) { + delay(3_000) + + val newPosition = when (Random.nextInt(3)) { + 0 -> singapore + 1 -> singapore2 + 2 -> singapore3 + else -> singapore + } + + dataModel.locationData = LocationData(newPosition) + } + } + + setContent { + MapsComposeSampleTheme { + GoogleMapWithSimpleMarker( + locationData = dataModel.locationData, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun GoogleMapWithSimpleMarker( + locationData: LocationData, + modifier: Modifier = Modifier, +) { + val cameraPositionState = rememberCameraPositionState { position = defaultCameraPosition } + + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState, + ) { + Marker(position = locationData.position) + } +} + +/** + * Standard API pattern for a non-draggable Marker. + * + * The caller does not have to deal with MarkerState, + * and can update Marker [position] via recomposition. + */ +@Composable +fun Marker( + position: LatLng, + onClick: () -> Boolean = { false }, +) { + val markerState = rememberUpdatedMarkerState(position) + + Marker( + state = markerState, + onClick = { onClick() } + ) +} + +/** + * Standard API pattern for remembering, initializing, and updating MarkerState for a + * non-draggable Marker, where [position] comes from a data model. + * + * Implementation modeled after `rememberUpdatedState`. + * + * This one uses [remember] behind the scenes, not [rememberMarkerState], which uses + * `rememberSaveable`. Our data model is the source of truth - `rememberSaveable` would + * create a conflicting source of truth. + */ +@Composable +fun rememberUpdatedMarkerState(position: LatLng): MarkerState = + // This pattern is equivalent to what rememberUpdatedState() does: + // rememberUpdatedState() uses MutableState, we use MarkerState. + // This is more efficient than updating position in an effect, + // as we avoid an additional recomposition. + remember { MarkerState(position = position) }.also { + it.position = position + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b74c4b9..6638f6ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,8 +21,13 @@ Advanced Markers Map In Column Marker Clustering + Marker Drag Events + Markers Collection Location Tracking Scale Bar + Syncing Draggable Marker With Model + Updating Non-Draggable Marker With Model + Polygon around draggable markers Recomposition Map Street View Custom Location Button diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt index 36e488b0..f1793069 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt @@ -142,45 +142,70 @@ internal class MapApplier( ) } map.setOnMarkerDragListener(object : GoogleMap.OnMarkerDragListener { - override fun onMarkerDrag(marker: Marker) { + // We update MarkerState isDragging & position properties in a specific well-defined + // order: MarkerState.position is never updated by us unless + // MarkerState.isDragging == true. This avoids using Snapshots, which can fail to apply; + // they would not be meaningful here, because we are not the actual source of truth. + + override fun onMarkerDragStart(marker: Marker) { decorations.findInputCallback( nodeMatchPredicate = { it.marker == marker }, + marker = marker, nodeInputCallback = { { - markerState.position = it.position - markerState.dragState = DragState.DRAG + val position = it.position + + markerState.isDragging = true + // update position after enabling isDragging + markerState.position = position + + @Suppress("DEPRECATION") + markerState.dragState = DragState.START } }, - marker = marker, - inputHandlerCallback = { onMarkerDrag } + inputHandlerCallback = { onMarkerDragStart } ) } - override fun onMarkerDragEnd(marker: Marker) { + override fun onMarkerDrag(marker: Marker) { decorations.findInputCallback( nodeMatchPredicate = { it.marker == marker }, - marker = marker, nodeInputCallback = { { - markerState.position = it.position - markerState.dragState = DragState.END + val position = it.position + + markerState.isDragging = true // just in case, should be set already + // update position after enabling isDragging + markerState.position = position + + @Suppress("DEPRECATION") + markerState.dragState = DragState.DRAG } }, - inputHandlerCallback = { onMarkerDragEnd } + marker = marker, + inputHandlerCallback = { onMarkerDrag } ) } - override fun onMarkerDragStart(marker: Marker) { + override fun onMarkerDragEnd(marker: Marker) { decorations.findInputCallback( nodeMatchPredicate = { it.marker == marker }, marker = marker, nodeInputCallback = { { - markerState.position = it.position - markerState.dragState = DragState.START + val position = it.position + + markerState.isDragging = true // just in case, should be set already + // update position after enabling isDragging + markerState.position = position + // disable isDragging after updating position + markerState.isDragging = false + + @Suppress("DEPRECATION") + markerState.dragState = DragState.END } }, - inputHandlerCallback = { onMarkerDragStart } + inputHandlerCallback = { onMarkerDragEnd } ) } }) diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt b/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt index 796ce3a3..25ddab7e 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt @@ -63,6 +63,7 @@ internal class MarkerNode( } @Immutable +@Deprecated("START, DRAG, END are events, not states. Avoid usage.") public enum class DragState { START, DRAG, END } @@ -70,6 +71,8 @@ public enum class DragState { /** * A state object that can be hoisted to control and observe the marker state. * + * This cannot be used to preserve marker info window visibility across configuration changes. + * * @param position the initial marker position */ public class MarkerState( @@ -77,12 +80,35 @@ public class MarkerState( ) { /** * Current position of the marker. + * + * This property is backed by Compose state. + * It can be updated by the API user and by the API itself: + * it has two potentially competing sources of truth. + * + * The API will not update the property unless [isDragging] `== true`, + * which will happen if and only if a Marker is draggable and the end user + * is currently dragging it. */ public var position: LatLng by mutableStateOf(position) + /** + * Reflects whether the end user is currently dragging the marker. + * Dragging can happen only if a Marker is draggable. + * + * This property is backed by Compose state. + */ + public var isDragging: Boolean by mutableStateOf(false) + internal set + /** * Current [DragState] of the marker. */ + @Deprecated( + "Use isDragging instead - dragState is not appropriate for representing \"state\";" + + " it is a lossy representation of drag \"events\", promoting invalid usage.", + level = DeprecationLevel.WARNING + ) + @Suppress("DEPRECATION") public var dragState: DragState by mutableStateOf(DragState.END) internal set @@ -99,14 +125,28 @@ public class MarkerState( } /** - * Shows the info window for the underlying marker + * Shows the info window for the underlying marker. + * + * Not backed by Compose state to accommodate + * [com.google.android.gms.maps.GoogleMap] special semantics: + * only a single info window can be visible for the entire GoogleMap. + * + * Only use from Compose Effect APIs, never directly from composition, to avoid exceptions and + * unexpected behavior from cancelled compositions. */ public fun showInfoWindow() { marker?.showInfoWindow() } /** - * Hides the info window for the underlying marker + * Hides the info window for the underlying marker. + * + * Not backed by observable Compose state to accommodate + * [com.google.android.gms.maps.GoogleMap] special semantics: + * only a single info window can be visible for the entire GoogleMap. + * + * Only use from Compose Effect APIs, never directly from composition, to avoid + * unexpected behavior from cancelled compositions. */ public fun hideInfoWindow() { marker?.hideInfoWindow() @@ -115,6 +155,9 @@ public class MarkerState( public companion object { /** * The default saver implementation for [MarkerState] + * + * This cannot be used to preserve marker info window visibility across + * configuration changes. */ public val Saver: Saver = Saver( save = { it.position }, @@ -123,6 +166,14 @@ public class MarkerState( } } +/** + * Uses [rememberSaveable] to retain [MarkerState.position] across configuration changes, + * for simple use cases. + * + * Other use cases may be better served syncing [MarkerState.position] with a data model. + * + * This cannot be used to preserve info window visibility across configuration changes. + */ @Composable public fun rememberMarkerState( key: String? = null,