diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4d4f57220..87cb6d948 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,4 +14,4 @@ # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners -.github/ @googlemaps/dpe +.github/ @googlemaps/admin diff --git a/.github/autoapproval.yml b/.github/autoapproval.yml index ff85d0dbe..1a97fd172 100644 --- a/.github/autoapproval.yml +++ b/.github/autoapproval.yml @@ -1,3 +1,17 @@ +# Copyright 2022 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. + from_owner: - dependabot diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index 334554212..34a1073e6 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,4 +1,18 @@ +# Copyright 2022 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. + assign_issues: - - arriolac + - barbeau assign_prs: - - arriolac + - barbeau diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6f0dac88e..ea94225d9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,30 @@ +# Copyright 2022 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. + version: 2 updates: - package-ecosystem: gradle directory: "/./app" schedule: - interval: daily + interval: "weekly" open-pull-requests-limit: 10 commit-message: prefix: chore(deps) - package-ecosystem: gradle directory: "/./maps-compose" schedule: - interval: daily + interval: "weekly" open-pull-requests-limit: 10 commit-message: prefix: chore(deps) diff --git a/.github/stale.yml b/.github/stale.yml index 8ed0e080f..1d39e65d7 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,3 +1,17 @@ +# Copyright 2022 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. + # Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml new file mode 100644 index 000000000..a7b2d39c0 --- /dev/null +++ b/.github/sync-repo-settings.yaml @@ -0,0 +1,44 @@ +# Copyright 2022 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. + +# https://github.com/googleapis/repo-automation-bots/tree/main/packages/sync-repo-settings + +rebaseMergeAllowed: true +squashMergeAllowed: true +mergeCommitAllowed: false +deleteBranchOnMerge: true +branchProtectionRules: +- pattern: main + isAdminEnforced: false + requiresStrictStatusChecks: false + requiredStatusCheckContexts: + - 'cla/google' + - 'test' + - 'snippet-bot check' + - 'header-check' + requiredApprovingReviewCount: 1 + requiresCodeOwnerReviews: true +- pattern: master + isAdminEnforced: false + requiresStrictStatusChecks: false + requiredStatusCheckContexts: + - 'cla/google' + - 'test' + - 'snippet-bot check' + - 'header-check' + requiredApprovingReviewCount: 1 + requiresCodeOwnerReviews: true +permissionRules: + - team: admin + permission: admin diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml new file mode 100644 index 000000000..6b46ebbbf --- /dev/null +++ b/.github/workflows/dependabot.yml @@ -0,0 +1,32 @@ +# Copyright 2022 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. + +name: Dependabot +on: pull_request + +permissions: + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.SYNCED_GITHUB_TOKEN_REPO}} + steps: + - name: approve + run: gh pr review --approve "$PR_URL" + - name: merge + run: gh pr merge --auto --squash --delete-branch "$PR_URL" diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml new file mode 100644 index 000000000..294355125 --- /dev/null +++ b/.github/workflows/instrumentation-test.yml @@ -0,0 +1,69 @@ +# Copyright 2020 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. + +# A workflow that runs tests on every new pull request +name: Run instrumentation tests + +on: + repository_dispatch: + types: [test] + push: + branches-ignore: ['gh-pages'] + pull_request: + branches-ignore: ['gh-pages'] + workflow_dispatch: + +jobs: + run-instrumentation-test: + runs-on: macOS-latest # enables hardware acceleration in the virtual machine + timeout-minutes: 30 + steps: + - name: Checkout Repo + uses: actions/checkout@v2 + + - name: Gradle Wrapper Validation + uses: gradle/wrapper-validation-action@v1.0.4 + + - name: Set up JDK 11 + uses: actions/setup-java@v2.3.1 + with: + java-version: '11' + distribution: 'adopt' + + - name: Inject Maps API Key + run: | + # Injecting the key directly into the manifest as secrets-gradle-plugin + # isn't picking up the key when creating a local.properties file here + sed -i -e "s,\${MAPS_API_KEY},$MAPS_API_KEY,g" ./app/src/main/AndroidManifest.xml + env: + MAPS_API_KEY: ${{ secrets.GMP_API_KEY }} + + - name: Build debug + run: ./gradlew assembleDebug + + - name: Run instrumentation tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: google_apis + arch: x86 + disable-animations: true + script: ./gradlew :app:connectedCheck --stacktrace + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v2 + with: + name: test-reports + path: ./app/build/reports diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fb92912f..049db65b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} - uses: gradle/wrapper-validation-action@v1.0.4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74abaa9e0..4fc6ad0e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,22 +25,22 @@ on: workflow_dispatch: jobs: - run-unit-test: + test: runs-on: ubuntu-latest steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Gradle Wrapper Validation uses: gradle/wrapper-validation-action@v1.0.4 - name: Set up JDK 11 - uses: actions/setup-java@v2.3.1 + uses: actions/setup-java@v3 with: java-version: '11' - distribution: 'adopt' + distribution: 'temurin' - name: Build modules run: ./gradlew build jacocoTestReport --stacktrace diff --git a/README.md b/README.md index d6b4079bf..ceddfb994 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This repository contains [Jetpack Compose][jetpack-compose] components for the M ## Requirements * Kotlin-enabled project -* Jetpack Compose-enabled project +* Jetpack Compose-enabled project (see [releases](https://github.com/googlemaps/android-maps-compose/releases) for the required version of Jetpack Compose) * An [API key][api-key] * API level 21+ @@ -21,50 +21,76 @@ Adding a map to your app looks like the following: ```kotlin val singapore = LatLng(1.35, 103.87) +val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(singapore, 10f) +} GoogleMap( modifier = Modifier.fillMaxSize(), - googleMapOptionsFactory = { - GoogleMapOptions().camera(CameraPosition.fromLatLngZoom(singapore, 10f)) - } + cameraPositionState = cameraPositionState ) ``` ### Creating and configuring a map -Configuring the map can be done either by passing a `GoogleMapOptions` instance -to initialize the map, or by passing a `MapProperties` object into the `GoogleMap` -composable. +Configuring the map can be done by passing a `MapProperties` object into the +`GoogleMap` composable, or for UI-related configurations, use `MapUiSettings`. +`MapProperties` and `MapUiSettings` should be your first go-to for configuring +the map. For any other configuration not present in those two classes, use +`googleMapOptionsFactory` to provide a `GoogleMapOptions` instance instead. +Typically, anything that can only be provided once (i.e. when the map is +created)—like map ID—should be provided via `googleMapOptionsFactory`. ```kotlin -// Initialize map by providing a googleMapOptionsFactory -GoogleMap( - googleMapOptionsFactory = { - GoogleMapOptions().mapId("MyMapId") - } -) - -// ...or set properties using MapProperties which you can use to recompose the map +// Set properties using MapProperties which you can use to recompose the map var mapProperties by remember { mutableStateOf( MapProperties(maxZoomPreference = 10f, minZoomPreference = 5f) ) } +var mapUiSettings by remember { + mutableStateOf( + MapUiSettings(mapToolbarEnabled = false) + ) +} Box(Modifier.fillMaxSize()) { - GoogleMap(properties = mapProperties) - Button(onClick = { - mapProperties = mapProperties.copy( - isBuildingEnabled = !mapProperties.isBuildingEnabled - ) - }) { - Text(text = "Toggle isBuildingEnabled") + GoogleMap(properties = mapProperties, uiSettings = mapUiSettings) + Column { + Button(onClick = { + mapProperties = mapProperties.copy( + isBuildingEnabled = !mapProperties.isBuildingEnabled + ) + }) { + Text(text = "Toggle isBuildingEnabled") + } + Button(onClick = { + mapUiSettings = mapUiSettings.copy( + mapToolbarEnabled = !mapUiSettings.mapToolbarEnabled + ) + }) { + Text(text = "Toggle mapToolbarEnabled") + } } } + +// ...or initialize the map by providing a googleMapOptionsFactory +// This should only be used for values that do not recompose the map such as +// map ID. +GoogleMap( + googleMapOptionsFactory = { + GoogleMapOptions().mapId("MyMapId") + } +) + ``` ### Controlling a map's camera Camera changes and updates can be observed and controlled via `CameraPositionState`. +**Note**: `CameraPositionState` is the source of truth for anything camera +related. So, providing a camera position in `GoogleMapOptions` will be +overridden by `CameraPosition`. + ```kotlin val singapore = LatLng(1.35, 103.87) val cameraPositionState: CameraPositionState = rememberCameraPositionState { @@ -90,8 +116,14 @@ composable elements to the content of the `GoogleMap`. GoogleMap( //... ) { - Marker(position = LatLng(-34, 151), title = "Marker in Sydney") - Marker(position = LatLng(35.66, 139.6), title = "Marker in Tokyo") + Marker( + state = MarkerState(position = LatLng(-34, 151)), + title = "Marker in Sydney" + ) + Marker( + state = MarkerState(position = LatLng(35.66, 139.6)), + title = "Marker in Tokyo" + ) } ``` @@ -134,10 +166,13 @@ To run it, you'll have to: ```groovy dependencies { - implementation 'com.google.maps.android:maps-compose:1.2.0' + implementation 'com.google.maps.android:maps-compose:2.2.1' // Make sure to also include the latest version of the Maps SDK for Android implementation 'com.google.android.gms:play-services-maps:18.0.2' + + // Also include Compose version `1.2.0-alpha03` or higher - for example: + implementation "androidx.compose.foundation:foundation:2.2.1-alpha03" } ``` diff --git a/app/build.gradle b/app/build.gradle index 64d7d8c95..6ae0c4b50 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,7 +27,7 @@ android { } buildFeatures { - buildConfig false + buildConfig true compose true } @@ -36,22 +36,40 @@ android { } } +// [START maps_android_compose_dependency] dependencies { - implementation project(':maps-compose') + // [START_EXCLUDE silent] implementation 'androidx.activity:activity-compose:1.4.0' implementation "androidx.compose.foundation:foundation:$compose_version" implementation "androidx.compose.material:material:$compose_version" - implementation 'androidx.core:core-ktx:1.7.0' - implementation 'com.google.android.gms:play-services-maps:18.0.2' implementation 'com.google.android.material:material:1.5.0' implementation 'com.google.maps.android:maps-ktx:3.3.0' - implementation "androidx.core:core-ktx:1.7.0" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + androidTestImplementation "androidx.test:core:$androidx_test_version" + androidTestImplementation "androidx.test:rules:$androidx_test_version" + androidTestImplementation "androidx.test:runner:$androidx_test_version" + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' + androidTestImplementation 'junit:junit:4.13.2' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" + androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0" + + // For debugging purposes, uncomment the implementation project declaration + // so that the sample app can use the local state of the `maps-compose` + // module. + // However, this should remain uncommented on the `main` branch so that + // the maven declaration of Maps Compose can be used as a snippet. + // implementation project(':maps-compose') + // [END_EXCLUDE] + implementation "com.google.maps.android:maps-compose:2.2.0" + implementation 'com.google.android.gms:play-services-maps:18.0.2' } +// [END maps_android_compose_dependency] secrets { // To add your Maps API key to this project: // 1. Add this line to your local.properties file, where YOUR_API_KEY is your API key: // MAPS_API_KEY=YOUR_API_KEY defaultPropertiesFileName 'local.defaults.properties' -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt b/app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt new file mode 100644 index 000000000..46b72c211 --- /dev/null +++ b/app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewTests.kt @@ -0,0 +1,234 @@ +// Copyright 2022 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 + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class GoogleMapViewTests { + @get:Rule + val composeTestRule = createComposeRule() + + private val startingZoom = 10f + private val startingPosition = LatLng(1.23, 4.56) + private lateinit var cameraPositionState: CameraPositionState + + private fun initMap(content: @Composable () -> Unit = {}) { + val countDownLatch = CountDownLatch(1) + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + onMapLoaded = { + countDownLatch.countDown() + } + ) { + content.invoke() + } + } + val mapLoaded = countDownLatch.await(30, TimeUnit.SECONDS) + assertTrue("Map loaded", mapLoaded) + } + + @Before + fun setUp() { + cameraPositionState = CameraPositionState( + position = CameraPosition.fromLatLngZoom( + startingPosition, + startingZoom + ) + ) + } + + @Test + fun testStartingCameraPosition() { + initMap() + startingPosition.assertEquals(cameraPositionState.position.target) + } + + @Test + fun testCameraReportsMoving() { + initMap() + zoom(shouldAnimate = true, zoomIn = true) { + composeTestRule.waitUntil(1000) { + cameraPositionState.isMoving + } + assertTrue(cameraPositionState.isMoving) + } + } + + @Test + fun testCameraReportsNotMoving() { + initMap() + zoom(shouldAnimate = true, zoomIn = true) { + composeTestRule.waitUntil(1000) { + cameraPositionState.isMoving + } + composeTestRule.waitUntil(5000) { + !cameraPositionState.isMoving + } + assertFalse(cameraPositionState.isMoving) + } + } + + @Test + fun testCameraZoomInAnimation() { + initMap() + zoom(shouldAnimate = true, zoomIn = true) { + composeTestRule.waitUntil(1000) { + cameraPositionState.isMoving + } + composeTestRule.waitUntil(3000) { + !cameraPositionState.isMoving + } + assertEquals( + startingZoom + 1f, + cameraPositionState.position.zoom, + assertRoundingError.toFloat() + ) + } + } + + @Test + fun testCameraZoomIn() { + initMap() + zoom(shouldAnimate = false, zoomIn = true) { + composeTestRule.waitUntil(1000) { + cameraPositionState.isMoving + } + composeTestRule.waitUntil(3000) { + !cameraPositionState.isMoving + } + assertEquals( + startingZoom + 1f, + cameraPositionState.position.zoom, + assertRoundingError.toFloat() + ) + } + } + + @Test + fun testCameraZoomOut() { + initMap() + zoom(shouldAnimate = false, zoomIn = false) { + composeTestRule.waitUntil(1000) { + cameraPositionState.isMoving + } + composeTestRule.waitUntil(3000) { + !cameraPositionState.isMoving + } + assertEquals( + startingZoom - 1f, + cameraPositionState.position.zoom, + assertRoundingError.toFloat() + ) + } + } + + @Test + fun testCameraZoomOutAnimation() { + initMap() + zoom(shouldAnimate = true, zoomIn = false) { + composeTestRule.waitUntil(1000) { + cameraPositionState.isMoving + } + composeTestRule.waitUntil(3000) { + !cameraPositionState.isMoving + } + assertEquals( + startingZoom - 1f, + cameraPositionState.position.zoom, + assertRoundingError.toFloat() + ) + } + } + + @Test + fun testLatLngInVisibleRegion() { + initMap() + composeTestRule.runOnUiThread { + val projection = cameraPositionState.projection + assertNotNull(projection) + assertTrue( + projection!!.visibleRegion.latLngBounds.contains(startingPosition) + ) + } + } + + @Test + fun testLatLngNotInVisibleRegion() { + initMap() + composeTestRule.runOnUiThread { + val projection = cameraPositionState.projection + assertNotNull(projection) + val latLng = LatLng(23.4, 25.6) + assertFalse( + projection!!.visibleRegion.latLngBounds.contains(latLng) + ) + } + } + + @Test(expected = IllegalStateException::class) + fun testMarkerStateCannotBeReused() { + initMap { + val markerState = rememberMarkerState() + Marker( + state = markerState + ) + Marker( + state = markerState + ) + } + } + + @Test + fun testCameraPositionStateMapClears() { + initMap() + composeTestRule.onNodeWithTag("toggleMapVisibility") + .performClick() + .performClick() + } + + private fun zoom( + shouldAnimate: Boolean, + zoomIn: Boolean, + assertionBlock: () -> Unit + ) { + if (!shouldAnimate) { + composeTestRule.onNodeWithTag("cameraAnimations") + .assertIsDisplayed() + .performClick() + } + composeTestRule.onNodeWithText(if (zoomIn) "+" else "-") + .assertIsDisplayed() + .performClick() + + assertionBlock() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/google/maps/android/compose/MapInColumnTests.kt b/app/src/androidTest/java/com/google/maps/android/compose/MapInColumnTests.kt new file mode 100644 index 000000000..e944944c6 --- /dev/null +++ b/app/src/androidTest/java/com/google/maps/android/compose/MapInColumnTests.kt @@ -0,0 +1,141 @@ +// Copyright 2022 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 + +import android.util.Log +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +private const val TAG = "MapInColumnTests" + +class MapInColumnTests { + @get:Rule + val composeTestRule = createComposeRule() + + private val startingZoom = 10f + private val startingPosition = LatLng(1.23, 4.56) + private lateinit var cameraPositionState: CameraPositionState + + private fun initMap(content: @Composable () -> Unit = {}) { + val countDownLatch = CountDownLatch(1) + composeTestRule.setContent { + var scrollingEnabled by remember { mutableStateOf(true) } + + LaunchedEffect(cameraPositionState.isMoving) { + if (!cameraPositionState.isMoving) { + scrollingEnabled = true + Log.d(TAG, "Map camera stopped moving - Enabling column scrolling...") + } + } + + MapInColumn( + modifier = Modifier.fillMaxSize(), + cameraPositionState, + columnScrollingEnabled = scrollingEnabled, + onMapTouched = { + scrollingEnabled = false + Log.d( + TAG, + "User touched map - Disabling column scrolling after user touched this Box..." + ) + }, + onMapLoaded = { + countDownLatch.countDown() + } + ) + } + val mapLoaded = countDownLatch.await(30, TimeUnit.SECONDS) + assertTrue("Map loaded", mapLoaded) + } + + @Before + fun setUp() { + cameraPositionState = CameraPositionState( + position = CameraPosition.fromLatLngZoom( + startingPosition, + startingZoom + ) + ) + } + + @Test + fun testStartingCameraPosition() { + initMap() + startingPosition.assertEquals(cameraPositionState.position.target) + } + + @Test + fun testLatLngInVisibleRegion() { + initMap() + composeTestRule.runOnUiThread { + val projection = cameraPositionState.projection + assertNotNull(projection) + assertTrue( + projection!!.visibleRegion.latLngBounds.contains(startingPosition) + ) + } + } + + @Test + fun testLatLngNotInVisibleRegion() { + initMap() + composeTestRule.runOnUiThread { + val projection = cameraPositionState.projection + assertNotNull(projection) + val latLng = LatLng(23.4, 25.6) + assertFalse( + projection!!.visibleRegion.latLngBounds.contains(latLng) + ) + } + } + + @Test + fun testScrollColumn_MapCameraRemainsSame() { + initMap() + // Check that the column scrolls to the last item + composeTestRule.onRoot().performTouchInput { swipeUp() } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("Item 1").assertIsNotDisplayed() + + // Check that the map didn't change + startingPosition.assertEquals(cameraPositionState.position.target) + } + +// @Test +// fun testPanMapUp_MapCameraChangesColumnDoesNotScroll() { +// initMap() +// // Swipe the map up +// // FIXME - for some reason this scrolls the entire column instead of just the map +// composeTestRule.onNodeWithTag("Map").performTouchInput { swipeUp() } +// composeTestRule.waitForIdle() +// +// // Make sure that the map changed (i.e., we can scroll the map in the column) +// startingPosition.assertNotEquals(cameraPositionState.position.target) +// +// // Check to make sure column didn't scroll +// composeTestRule.onNodeWithTag("Item 1").assertIsDisplayed() +// } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/google/maps/android/compose/TestUtils.kt b/app/src/androidTest/java/com/google/maps/android/compose/TestUtils.kt new file mode 100644 index 000000000..9ff769a6b --- /dev/null +++ b/app/src/androidTest/java/com/google/maps/android/compose/TestUtils.kt @@ -0,0 +1,17 @@ +package com.google.maps.android.compose + +import com.google.android.gms.maps.model.LatLng +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals + +const val assertRoundingError: Double = 0.01 + +fun LatLng.assertEquals(other: LatLng) { + assertEquals(latitude, other.latitude, assertRoundingError) + assertEquals(longitude, other.longitude, assertRoundingError) +} + +fun LatLng.assertNotEquals(other: LatLng) { + assertNotEquals(latitude, other.latitude, assertRoundingError) + assertNotEquals(longitude, other.longitude, assertRoundingError) +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2d82c36d..0120d5149 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,14 +33,22 @@ android:value="${MAPS_API_KEY}" /> + + - + + + \ No newline at end of file diff --git a/app/src/main/java/com/google/maps/android/compose/MapSampleActivity.kt b/app/src/main/java/com/google/maps/android/compose/BasicMapActivity.kt similarity index 53% rename from app/src/main/java/com/google/maps/android/compose/MapSampleActivity.kt rename to app/src/main/java/com/google/maps/android/compose/BasicMapActivity.kt index afde47747..d8871816c 100644 --- a/app/src/main/java/com/google/maps/android/compose/MapSampleActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/BasicMapActivity.kt @@ -24,52 +24,45 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Switch -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.google.android.gms.maps.CameraUpdateFactory -import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.model.BitmapDescriptorFactory 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 kotlinx.coroutines.launch -private const val TAG = "MapSampleActivity" +private const val TAG = "BasicMapActivity" -class MapSampleActivity : ComponentActivity() { +private val singapore = LatLng(1.35, 103.87) +private val singapore2 = LatLng(1.40, 103.77) +private val singapore3 = LatLng(1.45, 103.77) +private val defaultCameraPosition = CameraPosition.fromLatLngZoom(singapore, 11f) + +class BasicMapActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { var isMapLoaded by remember { mutableStateOf(false) } + // Observing and controlling the camera's state can be done with a CameraPositionState + val cameraPositionState = rememberCameraPositionState { + position = defaultCameraPosition + } Box(Modifier.fillMaxSize()) { GoogleMapView( modifier = Modifier.matchParentSize(), + cameraPositionState = cameraPositionState, onMapLoaded = { isMapLoaded = true - } + }, ) if (!isMapLoaded) { AnimatedVisibility( @@ -92,77 +85,99 @@ class MapSampleActivity : ComponentActivity() { } @Composable -private fun GoogleMapView(modifier: Modifier, onMapLoaded: () -> Unit) { - val singapore = LatLng(1.35, 103.87) - val singapore2 = LatLng(1.40, 103.77) - - // Observing and controlling the camera's state can be done with a CameraPositionState - val cameraPositionState = rememberCameraPositionState { - position = CameraPosition.fromLatLngZoom(singapore, 11f) +fun GoogleMapView( + modifier: Modifier, + cameraPositionState: CameraPositionState, + onMapLoaded: () -> Unit, + content: @Composable () -> Unit = {} +) { + val singaporeState = rememberMarkerState(position = singapore) + val singapore2State = rememberMarkerState(position = singapore2) + val singapore3State = rememberMarkerState(position = singapore3) + var circleCenter by remember { mutableStateOf(singapore) } + if (singaporeState.dragState == DragState.END) { + circleCenter = singaporeState.position } + var uiSettings by remember { mutableStateOf(MapUiSettings(compassEnabled = false)) } + var shouldAnimateZoom by remember { mutableStateOf(true) } + var ticker by remember { mutableStateOf(0) } var mapProperties by remember { mutableStateOf(MapProperties(mapType = MapType.NORMAL)) } - var uiSettings by remember { - mutableStateOf( - MapUiSettings(compassEnabled = false) - ) - } - var shouldAnimateZoom by remember { mutableStateOf(true) } - var ticker by remember { mutableStateOf(0) } + var mapVisible by remember { mutableStateOf(true) } - GoogleMap( - modifier = modifier, - cameraPositionState = cameraPositionState, - properties = mapProperties, - uiSettings = uiSettings, - onMapLoaded = onMapLoaded, - googleMapOptionsFactory = { - GoogleMapOptions().camera( - CameraPosition.fromLatLngZoom( - singapore, - 11f - ) - ) - }, - onPOIClick = { - Log.d(TAG, "POI clicked: ${it.name}") - } - ) { - // Drawing on the map is accomplished with a child-based API - val markerClick: (Marker) -> Boolean = { - Log.d(TAG, "${it.title} was clicked") - false - } - MarkerInfoWindowContent( - position = singapore, - title = "Zoom in has been tapped $ticker times.", - onClick = markerClick, - ) { - Text(it.title ?: "Title", color = Color.Red) - } - MarkerInfoWindowContent( - position = singapore2, - title = "Marker with custom info window.\nZoom in has been tapped $ticker times.", - icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE), - onClick = markerClick, + if (mapVisible) { + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState, + properties = mapProperties, + uiSettings = uiSettings, + onMapLoaded = onMapLoaded, + onPOIClick = { + Log.d(TAG, "POI clicked: ${it.name}") + } ) { - Text(it.title ?: "Title", color = Color.Blue) + // Drawing on the map is accomplished with a child-based API + val markerClick: (Marker) -> Boolean = { + Log.d(TAG, "${it.title} was clicked") + cameraPositionState.projection?.let { projection -> + Log.d(TAG, "The current projection is: $projection") + } + false + } + MarkerInfoWindowContent( + state = singaporeState, + title = "Zoom in has been tapped $ticker times.", + onClick = markerClick, + draggable = true, + ) { + Text(it.title ?: "Title", color = Color.Red) + } + MarkerInfoWindowContent( + state = singapore2State, + title = "Marker with custom info window.\nZoom in has been tapped $ticker times.", + icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE), + onClick = markerClick, + ) { + Text(it.title ?: "Title", color = Color.Blue) + } + Marker( + state = singapore3State, + title = "Marker in Singapore", + onClick = markerClick + ) + Circle( + center = circleCenter, + fillColor = MaterialTheme.colors.secondary, + strokeColor = MaterialTheme.colors.secondaryVariant, + radius = 1000.0, + ) + content() } - Circle( - center = singapore, - fillColor = MaterialTheme.colors.secondary, - strokeColor = MaterialTheme.colors.secondaryVariant, - radius = 1000.0, - ) - } + } Column { MapTypeControls(onMapTypeClick = { Log.d("GoogleMap", "Selected map type $it") mapProperties = mapProperties.copy(mapType = it) }) + Row { + MapButton( + text = "Reset Map", + onClick = { + mapProperties = mapProperties.copy(mapType = MapType.NORMAL) + cameraPositionState.position = defaultCameraPosition + singaporeState.position = singapore + singaporeState.hideInfoWindow() + } + ) + MapButton( + text = "Toggle Map", + onClick = { mapVisible = !mapVisible }, + modifier = Modifier.testTag("toggleMapVisibility"), + ) + } val coroutineScope = rememberCoroutineScope() ZoomControls( shouldAnimateZoom, @@ -193,7 +208,7 @@ private fun GoogleMapView(modifier: Modifier, onMapLoaded: () -> Unit) { uiSettings = uiSettings.copy(zoomControlsEnabled = it) } ) - DebugView(cameraPositionState) + DebugView(cameraPositionState, singaporeState) } } @@ -214,18 +229,8 @@ private fun MapTypeControls( } @Composable -private fun MapTypeButton(type: MapType, onClick: () -> Unit) { - Button( - modifier = Modifier.padding(4.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.onPrimary, - contentColor = MaterialTheme.colors.primary - ), - onClick = onClick - ) { - Text(text = type.toString(), style = MaterialTheme.typography.body1) - } -} +private fun MapTypeButton(type: MapType, onClick: () -> Unit) = + MapButton(text = type.toString(), onClick = onClick) @Composable private fun ZoomControls( @@ -240,40 +245,40 @@ private fun ZoomControls( MapButton("-", onClick = { onZoomOut() }) MapButton("+", onClick = { onZoomIn() }) Column(verticalArrangement = Arrangement.Center) { - Row(horizontalArrangement = Arrangement.Center) { - Text(text = "Camera Animations On?") - Switch( - isCameraAnimationChecked, - onCheckedChange = onCameraAnimationCheckedChange - ) - } - Row(horizontalArrangement = Arrangement.Center) { - Text(text = "Zoom Controls On?") - Switch( - isZoomControlsEnabledChecked, - onCheckedChange = onZoomControlsCheckedChange - ) - } + Text(text = "Camera Animations On?") + Switch( + isCameraAnimationChecked, + onCheckedChange = onCameraAnimationCheckedChange, + modifier = Modifier.testTag("cameraAnimations"), + ) + Text(text = "Zoom Controls On?") + Switch( + isZoomControlsEnabledChecked, + onCheckedChange = onZoomControlsCheckedChange + ) } } } @Composable -private fun MapButton(text: String, onClick: () -> Unit) { +private fun MapButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { Button( - modifier = Modifier.padding(8.dp), + modifier = modifier.padding(4.dp), colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.onPrimary, contentColor = MaterialTheme.colors.primary ), onClick = onClick ) { - Text(text = text, style = MaterialTheme.typography.h5) + Text(text = text, style = MaterialTheme.typography.body1) } } @Composable -private fun DebugView(cameraPositionState: CameraPositionState) { +private fun DebugView( + cameraPositionState: CameraPositionState, + markerState: MarkerState +) { Column( Modifier .fillMaxWidth(), @@ -283,5 +288,10 @@ private fun DebugView(cameraPositionState: CameraPositionState) { if (cameraPositionState.isMoving) "moving" else "not moving" Text(text = "Camera is $moving") Text(text = "Camera position is ${cameraPositionState.position}") + Spacer(modifier = Modifier.height(4.dp)) + val dragging = + if (markerState.dragState == DragState.DRAG) "dragging" else "not dragging" + Text(text = "Marker is $dragging") + Text(text = "Marker position is ${markerState.position}") } -} \ No newline at end of file +} 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 new file mode 100644 index 000000000..02c49847c --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/MainActivity.kt @@ -0,0 +1,74 @@ +// Copyright 2021 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 + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +private const val TAG = "MapSampleActivity" + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colors.background + ) { + val context = LocalContext.current + Column( + Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.padding(10.dp)) + Text( + text = getString(R.string.main_activity_title), + style = MaterialTheme.typography.h5 + ) + Spacer(modifier = Modifier.padding(10.dp)) + Button( + onClick = { + context.startActivity(Intent(context, BasicMapActivity::class.java)) + }) { + Text(getString(R.string.basic_map_activity)) + } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, MapInColumnActivity::class.java)) + }) { + Text(getString(R.string.map_in_column_activity)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/google/maps/android/compose/MapInColumnActivity.kt b/app/src/main/java/com/google/maps/android/compose/MapInColumnActivity.kt new file mode 100644 index 000000000..8aad53c2a --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/MapInColumnActivity.kt @@ -0,0 +1,220 @@ +// Copyright 2021 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 + +import android.os.Bundle +import android.util.Log +import android.view.MotionEvent +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker + +private const val TAG = "ScrollingMapActivity" + +private val singapore = LatLng(1.35, 103.87) +private val defaultCameraPosition = CameraPosition.fromLatLngZoom(singapore, 11f) + +class MapInColumnActivity : ComponentActivity() { + + @OptIn(ExperimentalComposeUiApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + // Observing and controlling the camera's state can be done with a CameraPositionState + val cameraPositionState = rememberCameraPositionState { + position = defaultCameraPosition + } + var columnScrollingEnabled by remember { mutableStateOf(true) } + + // Use a LaunchedEffect keyed on the camera moving state to enable column scrolling when the camera stops moving + LaunchedEffect(cameraPositionState.isMoving) { + if (!cameraPositionState.isMoving) { + columnScrollingEnabled = true + Log.d(TAG, "Map camera stopped moving - Enabling column scrolling...") + } + } + + MapInColumn( + modifier = Modifier.fillMaxSize(), + cameraPositionState, + columnScrollingEnabled = columnScrollingEnabled, + onMapTouched = { + columnScrollingEnabled = false + Log.d( + TAG, + "User touched map - Disabling column scrolling after user touched this Box..." + ) + }, + onMapLoaded = { } + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun MapInColumn( + modifier: Modifier = Modifier, + cameraPositionState: CameraPositionState, + columnScrollingEnabled: Boolean, + onMapTouched: () -> Unit, + onMapLoaded: () -> Unit, +) { + Surface( + modifier = modifier, + color = MaterialTheme.colors.background + ) { + var isMapLoaded by remember { mutableStateOf(false) } + + Column( + Modifier + .fillMaxSize() + .verticalScroll( + rememberScrollState(), + columnScrollingEnabled + ), + horizontalAlignment = Alignment.Start + ) { + Spacer(modifier = Modifier.padding(10.dp)) + for (i in 1..20) { + Text( + text = "Item $i", + modifier = Modifier + .padding(start = 10.dp) + .testTag("Item $i") + ) + } + Spacer(modifier = Modifier.padding(10.dp)) + + Box( + Modifier + .fillMaxWidth() + .height(200.dp) + ) { + GoogleMapViewInColumn( + modifier = Modifier + .fillMaxSize() + .testTag("Map") + .pointerInteropFilter( + onTouchEvent = { + when (it.action) { + MotionEvent.ACTION_DOWN -> { + onMapTouched() + false + } + else -> { + Log.d( + TAG, + "MotionEvent ${it.action} - this never triggers." + ) + true + } + } + } + ), + cameraPositionState = cameraPositionState, + onMapLoaded = { + isMapLoaded = true + onMapLoaded() + }, + ) + if (!isMapLoaded) { + androidx.compose.animation.AnimatedVisibility( + modifier = Modifier + .fillMaxSize(), + visible = !isMapLoaded, + enter = EnterTransition.None, + exit = fadeOut() + ) { + CircularProgressIndicator( + modifier = Modifier + .background(MaterialTheme.colors.background) + .wrapContentSize() + ) + } + } + } + Spacer(modifier = Modifier.padding(10.dp)) + for (i in 21..40) { + Text( + text = "Item $i", + modifier = Modifier + .padding(start = 10.dp) + .testTag("Item $i") + ) + } + Spacer(modifier = Modifier.padding(10.dp)) + } + } +} + +@Composable +private fun GoogleMapViewInColumn( + modifier: Modifier, + cameraPositionState: CameraPositionState, + onMapLoaded: () -> Unit, +) { + val singaporeState = rememberMarkerState(position = singapore) + + var uiSettings by remember { mutableStateOf(MapUiSettings(compassEnabled = false)) } + var mapProperties by remember { + mutableStateOf(MapProperties(mapType = MapType.NORMAL)) + } + + GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState, + properties = mapProperties, + uiSettings = uiSettings, + onMapLoaded = onMapLoaded + ) { + // Drawing on the map is accomplished with a child-based API + val markerClick: (Marker) -> Boolean = { + Log.d(TAG, "${it.title} was clicked") + cameraPositionState.projection?.let { projection -> + Log.d(TAG, "The current projection is: $projection") + } + false + } + MarkerInfoWindowContent( + state = singaporeState, + title = "Singapore", + onClick = markerClick, + draggable = true, + ) { + Text(it.title ?: "Title", color = Color.Red) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 246066311..c8fb093ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,4 +16,7 @@ android-maps-compose + "Maps Compose Demos \uD83D\uDDFA" + Basic Map Activity + Map In Column Activity \ No newline at end of file diff --git a/build.gradle b/build.gradle index b0c1d0815..291e5fcf1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,15 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.6.10' - ext.compose_version = '1.2.0-alpha02' + ext.kotlin_version = '1.6.21' + ext.compose_version = '1.2.0-beta02' + ext.androidx_test_version = '1.4.0' repositories { google() mavenCentral() } dependencies { classpath "com.android.tools.build:gradle:7.0.4" - classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.0" + classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.5.0' classpath 'com.hiya:jacoco-android:0.2' @@ -29,7 +30,7 @@ ext.projectArtifactId = { project -> allprojects { group = 'com.google.maps.android' - version = '1.2.0' + version = '2.2.1' project.ext.artifactId = rootProject.ext.projectArtifactId(project) } @@ -124,8 +125,10 @@ subprojects { project -> repositories { maven { + def releasesRepoUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + def snapshotsRepoUrl = "https://oss.sonatype.org/content/repositories/snapshots/" name = "mavencentral" - url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + url = project.version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl credentials { username sonatypeUsername password sonatypePassword diff --git a/maps-compose/build.gradle b/maps-compose/build.gradle index 90ac6bc38..0e2dd7d6d 100644 --- a/maps-compose/build.gradle +++ b/maps-compose/build.gradle @@ -29,6 +29,7 @@ android { kotlinOptions { jvmTarget = '1.8' + freeCompilerArgs += '-Xexplicit-api=strict' } } diff --git a/maps-compose/src/androidTest/java/com/google/maps/android/compose/ExampleInstrumentedTest.kt b/maps-compose/src/androidTest/java/com/google/maps/android/compose/ExampleInstrumentedTest.kt index 4acacd6fb..10c71948f 100644 --- a/maps-compose/src/androidTest/java/com/google/maps/android/compose/ExampleInstrumentedTest.kt +++ b/maps-compose/src/androidTest/java/com/google/maps/android/compose/ExampleInstrumentedTest.kt @@ -28,7 +28,7 @@ import org.junit.Assert.* * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { +internal class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/CameraPositionState.kt b/maps-compose/src/main/java/com/google/maps/android/compose/CameraPositionState.kt index 2e28740e2..57bdae868 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/CameraPositionState.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/CameraPositionState.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import com.google.android.gms.maps.CameraUpdate import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.Projection import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import kotlinx.coroutines.CancellableContinuation @@ -32,6 +33,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.suspendCancellableCoroutine +import java.lang.Integer.MAX_VALUE import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -41,7 +43,7 @@ import kotlin.coroutines.resumeWithException * initial state. */ @Composable -inline fun rememberCameraPositionState( +public inline fun rememberCameraPositionState( key: String? = null, crossinline init: CameraPositionState.() -> Unit = {} ): CameraPositionState = rememberSaveable(key = key, saver = CameraPositionState.Saver) { @@ -55,16 +57,23 @@ inline fun rememberCameraPositionState( * * @param position the initial camera position */ -class CameraPositionState( +public class CameraPositionState( position: CameraPosition = CameraPosition(LatLng(0.0, 0.0), 0f, 0f, 0f) ) { /** * Whether the camera is currently moving or not. This includes any kind of movement: * panning, zooming, or rotation. */ - var isMoving by mutableStateOf(false) + public var isMoving: Boolean by mutableStateOf(false) internal set + /** + * Returns the current [Projection] to be used for converting between screen + * coordinates and lat/lng. + */ + public val projection: Projection? + get() = map?.projection + /** * Local source of truth for the current camera position. * While [map] is non-null this reflects the current position of [map] as it changes. @@ -76,7 +85,7 @@ class CameraPositionState( /** * Current position of the camera on the map. */ - var position: CameraPosition + public var position: CameraPosition get() = rawPosition set(value) { synchronized(lock) { @@ -165,9 +174,14 @@ class CameraPositionState( * suspend until a map is bound and animation will begin. * * This method should only be called from a dispatcher bound to the map's UI thread. + * + * @param update the change that should be applied to the camera + * @param durationMs The duration of the animation in milliseconds. If [Int.MAX_VALUE] is + * provided, the default animation duration will be used. Otherwise, the value provided must be + * strictly positive, otherwise an [IllegalArgumentException] will be thrown. */ @UiThread - suspend fun animate(update: CameraUpdate) { + public suspend fun animate(update: CameraUpdate, durationMs: Int = MAX_VALUE) { val myJob = currentCoroutineContext()[Job] try { suspendCancellableCoroutine { continuation -> @@ -187,7 +201,7 @@ class CameraPositionState( "internal error; no GoogleMap available to animate position" ) } - performAnimateCameraLocked(newMap, update, continuation) + performAnimateCameraLocked(newMap, update, durationMs, continuation) } override fun onCancelLocked() { @@ -208,7 +222,7 @@ class CameraPositionState( } } } else { - performAnimateCameraLocked(map, update, continuation) + performAnimateCameraLocked(map, update, durationMs, continuation) } } } @@ -227,9 +241,10 @@ class CameraPositionState( private fun performAnimateCameraLocked( map: GoogleMap, update: CameraUpdate, + durationMs: Int, continuation: CancellableContinuation ) { - map.animateCamera(update, object : GoogleMap.CancelableCallback { + val cancelableCallback = object : GoogleMap.CancelableCallback { override fun onCancel() { continuation.resumeWithException(CancellationException("Animation cancelled")) } @@ -237,7 +252,12 @@ class CameraPositionState( override fun onFinish() { continuation.resume(Unit) } - }) + } + if (durationMs == MAX_VALUE) { + map.animateCamera(update, cancelableCallback) + } else { + map.animateCamera(update, durationMs, cancelableCallback) + } doOnMapChangedLocked { check(it == null) { "New GoogleMap unexpectedly set while an animation was still running" @@ -256,7 +276,7 @@ class CameraPositionState( * This method must be called from the map's UI thread. */ @UiThread - fun move(update: CameraUpdate) { + public fun move(update: CameraUpdate) { synchronized(lock) { val map = map movementOwner = null @@ -269,11 +289,11 @@ class CameraPositionState( } } - companion object { + public companion object { /** * The default saver implementation for [CameraPositionState] */ - val Saver = Saver( + public val Saver: Saver = Saver( save = { it.position }, restore = { CameraPositionState(it) } ) diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/Circle.kt b/maps-compose/src/main/java/com/google/maps/android/compose/Circle.kt index 4958d1745..237af0450 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/Circle.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/Circle.kt @@ -50,7 +50,8 @@ internal class CircleNode( * @param onClick a lambda invoked when the circle is clicked */ @Composable -fun Circle( +@GoogleMapComposable +public fun Circle( center: LatLng, clickable: Boolean = false, fillColor: Color = Color.Transparent, diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt b/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt index 5fa063348..28571c5c7 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/ComposeInfoWindowAdapter.kt @@ -1,3 +1,17 @@ +// Copyright 2022 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 import android.view.View diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index 7f0a2bcb2..0111a53ca 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -15,20 +15,18 @@ package com.google.maps.android.compose import android.content.ComponentCallbacks -import android.content.Context import android.content.res.Configuration import android.location.Location import android.os.Bundle -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.Composition import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberUpdatedState @@ -38,7 +36,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.LocationSource import com.google.android.gms.maps.MapView @@ -66,10 +63,12 @@ import kotlinx.coroutines.awaitCancellation * @param onMyLocationButtonClick lambda invoked when the my location button is clicked * @param onMyLocationClick lambda invoked when the my location dot is clicked * @param onPOIClick lambda invoked when a POI is clicked + * @param contentPadding the padding values used to signal that portions of the map around the edges + * may be obscured. The map will move the Google logo, etc. to avoid overlapping the padding. * @param content the content of the map */ @Composable -fun GoogleMap( +public fun GoogleMap( modifier: Modifier = Modifier, cameraPositionState: CameraPositionState = rememberCameraPositionState(), contentDescription: String? = null, @@ -85,7 +84,7 @@ fun GoogleMap( onMyLocationClick: (Location) -> Unit = {}, onPOIClick: (PointOfInterest) -> Unit = {}, contentPadding: PaddingValues = NoPadding, - content: (@Composable () -> Unit)? = null, + content: (@Composable @GoogleMapComposable () -> Unit)? = null, ) { val context = LocalContext.current val mapView = remember { MapView(context, googleMapOptionsFactory()) } @@ -160,8 +159,9 @@ private suspend inline fun MapView.newComposition( private fun MapLifecycle(mapView: MapView) { val context = LocalContext.current val lifecycle = LocalLifecycleOwner.current.lifecycle + val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } DisposableEffect(context, lifecycle, mapView) { - val mapLifecycleObserver = mapView.lifecycleObserver() + val mapLifecycleObserver = mapView.lifecycleObserver(previousState) val callbacks = mapView.componentCallbacks() lifecycle.addObserver(mapLifecycleObserver) @@ -174,10 +174,18 @@ private fun MapLifecycle(mapView: MapView) { } } -private fun MapView.lifecycleObserver(): LifecycleEventObserver = +private fun MapView.lifecycleObserver(previousState: MutableState): LifecycleEventObserver = LifecycleEventObserver { _, event -> + event.targetState when (event) { - Lifecycle.Event.ON_CREATE -> this.onCreate(Bundle()) + Lifecycle.Event.ON_CREATE -> { + // Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in + // this case the GoogleMap composable also doesn't leave the composition. So, + // recreating the map does not restore state properly which must be avoided. + if (previousState.value != Lifecycle.Event.ON_STOP) { + this.onCreate(Bundle()) + } + } Lifecycle.Event.ON_START -> this.onStart() Lifecycle.Event.ON_RESUME -> this.onResume() Lifecycle.Event.ON_PAUSE -> this.onPause() @@ -185,6 +193,7 @@ private fun MapView.lifecycleObserver(): LifecycleEventObserver = Lifecycle.Event.ON_DESTROY -> this.onDestroy() else -> throw IllegalStateException() } + previousState.value = event } private fun MapView.componentCallbacks(): ComponentCallbacks = diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMapComposable.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMapComposable.kt new file mode 100644 index 000000000..56f3f04c9 --- /dev/null +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMapComposable.kt @@ -0,0 +1,21 @@ +package com.google.maps.android.compose + +import androidx.compose.runtime.ComposableTargetMarker + +/** + * An annotation that can be used to mark a composable function as being expected to be use in a + * composable function that is also marked or inferred to be marked as a [GoogleMapComposable]. + * + * This will produce build warnings when [GoogleMapComposable] composable functions are used outside + * of a [GoogleMapComposable] content lambda, and vice versa. + */ +@Retention(AnnotationRetention.BINARY) +@ComposableTargetMarker(description = "Google Map Composable") +@Target( + AnnotationTarget.FILE, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, +) +public annotation class GoogleMapComposable \ No newline at end of file diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GroundOverlay.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GroundOverlay.kt index 9e0d1a774..89828218d 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GroundOverlay.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GroundOverlay.kt @@ -40,18 +40,18 @@ internal class GroundOverlayNode( * * Use one of the [create] methods to construct an instance of this class. */ -class GroundOverlayPosition private constructor( - val latLngBounds: LatLngBounds? = null, - val location: LatLng? = null, - val width: Float? = null, - val height: Float? = null, +public class GroundOverlayPosition private constructor( + public val latLngBounds: LatLngBounds? = null, + public val location: LatLng? = null, + public val width: Float? = null, + public val height: Float? = null, ) { - companion object { - fun create(latLngBounds: LatLngBounds) : GroundOverlayPosition { + public companion object { + public fun create(latLngBounds: LatLngBounds) : GroundOverlayPosition { return GroundOverlayPosition(latLngBounds = latLngBounds) } - fun create(location: LatLng, width: Float, height: Float? = null) : GroundOverlayPosition { + public fun create(location: LatLng, width: Float, height: Float? = null) : GroundOverlayPosition { return GroundOverlayPosition( location = location, width = width, @@ -76,7 +76,8 @@ class GroundOverlayPosition private constructor( * @param onClick a lambda invoked when the ground overlay is clicked */ @Composable -fun GroundOverlay( +@GoogleMapComposable +public fun GroundOverlay( position: GroundOverlayPosition, image: BitmapDescriptor, anchor: Offset = Offset(0.5f, 0.5f), 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 cacc5c490..2940046d4 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 @@ -15,7 +15,6 @@ package com.google.maps.android.compose import androidx.compose.runtime.AbstractApplier -import androidx.compose.ui.platform.ComposeView import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.Circle @@ -27,6 +26,7 @@ import com.google.android.gms.maps.model.Polyline internal interface MapNode { fun onAttached() {} fun onRemoved() {} + fun onCleared() {} } private object MapNodeRoot : MapNode @@ -44,6 +44,8 @@ internal class MapApplier( override fun onClear() { map.clear() + decorations.forEach { it.onCleared() } + decorations.clear() } override fun insertBottomUp(index: Int, instance: MapNode) { @@ -112,21 +114,24 @@ internal class MapApplier( } map.setOnMarkerDragListener(object : GoogleMap.OnMarkerDragListener { override fun onMarkerDrag(marker: Marker) { - val markerDragState = - decorations.nodeForMarker(marker)?.markerDragState - markerDragState?.dragState = DragState.DRAG + with(decorations.nodeForMarker(marker)) { + this?.markerState?.position = marker.position + this?.markerState?.dragState = DragState.DRAG + } } override fun onMarkerDragEnd(marker: Marker) { - val markerDragState = - decorations.nodeForMarker(marker)?.markerDragState - markerDragState?.dragState = DragState.END + with(decorations.nodeForMarker(marker)) { + this?.markerState?.position = marker.position + this?.markerState?.dragState = DragState.END + } } override fun onMarkerDragStart(marker: Marker) { - val markerDragState = - decorations.nodeForMarker(marker)?.markerDragState - markerDragState?.dragState = DragState.START + with(decorations.nodeForMarker(marker)) { + this?.markerState?.position = marker.position + this?.markerState?.dragState = DragState.START + } } }) map.setInfoWindowAdapter( @@ -139,18 +144,18 @@ internal class MapApplier( } private fun MutableList.nodeForCircle(circle: Circle): CircleNode? = - first { it is CircleNode && it.circle == circle } as? CircleNode + firstOrNull { it is CircleNode && it.circle == circle } as? CircleNode private fun MutableList.nodeForMarker(marker: Marker): MarkerNode? = - first { it is MarkerNode && it.marker == marker } as? MarkerNode + firstOrNull { it is MarkerNode && it.marker == marker } as? MarkerNode private fun MutableList.nodeForPolygon(polygon: Polygon): PolygonNode? = - first { it is PolygonNode && it.polygon == polygon } as? PolygonNode + firstOrNull { it is PolygonNode && it.polygon == polygon } as? PolygonNode private fun MutableList.nodeForPolyline(polyline: Polyline): PolylineNode? = - first { it is PolylineNode && it.polyline == polyline } as? PolylineNode + firstOrNull { it is PolylineNode && it.polyline == polyline } as? PolylineNode private fun MutableList.nodeForGroundOverlay( groundOverlay: GroundOverlay ): GroundOverlayNode? = - first { it is GroundOverlayNode && it.groundOverlay == groundOverlay } as? GroundOverlayNode + firstOrNull { it is GroundOverlayNode && it.groundOverlay == groundOverlay } as? GroundOverlayNode diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapClickListeners.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapClickListeners.kt index fea55144a..1ffea9478 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapClickListeners.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapClickListeners.kt @@ -1,3 +1,17 @@ +// Copyright 2022 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 import android.location.Location @@ -12,22 +26,22 @@ import com.google.android.gms.maps.model.PointOfInterest * Default implementation of [IndoorStateChangeListener] with no-op * implementations. */ -object DefaultIndoorStateChangeListener : IndoorStateChangeListener +public object DefaultIndoorStateChangeListener : IndoorStateChangeListener /** * Interface definition for building indoor level state changes. */ -interface IndoorStateChangeListener { +public interface IndoorStateChangeListener { /** * Callback invoked when an indoor building comes to focus. */ - fun onIndoorBuildingFocused() {} + public fun onIndoorBuildingFocused() {} /** * Callback invoked when a level for a building is activated. * @param building the activated building */ - fun onIndoorLevelActivated(building: IndoorBuilding) {} + public fun onIndoorLevelActivated(building: IndoorBuilding) {} } /** diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapProperties.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapProperties.kt index 48d93039d..fcdd0604d 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapProperties.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapProperties.kt @@ -1,3 +1,17 @@ +// Copyright 2022 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 import com.google.android.gms.maps.model.LatLngBounds @@ -13,16 +27,16 @@ internal val DefaultMapProperties = MapProperties() * compatibility on future changes. * See: https://jakewharton.com/public-api-challenges-in-kotlin/ */ -class MapProperties( - val isBuildingEnabled: Boolean = false, - val isIndoorEnabled: Boolean = false, - val isMyLocationEnabled: Boolean = false, - val isTrafficEnabled: Boolean = false, - val latLngBoundsForCameraTarget: LatLngBounds? = null, - val mapStyleOptions: MapStyleOptions? = null, - val mapType: MapType = MapType.NORMAL, - val maxZoomPreference: Float = 21.0f, - val minZoomPreference: Float = 3.0f, +public class MapProperties( + public val isBuildingEnabled: Boolean = false, + public val isIndoorEnabled: Boolean = false, + public val isMyLocationEnabled: Boolean = false, + public val isTrafficEnabled: Boolean = false, + public val latLngBoundsForCameraTarget: LatLngBounds? = null, + public val mapStyleOptions: MapStyleOptions? = null, + public val mapType: MapType = MapType.NORMAL, + public val maxZoomPreference: Float = 21.0f, + public val minZoomPreference: Float = 3.0f, ) { override fun toString(): String = "MapProperties(" + "isBuildingEnabled=$isBuildingEnabled, isIndoorEnabled=$isIndoorEnabled, " + @@ -54,7 +68,7 @@ class MapProperties( minZoomPreference ) - fun copy( + public fun copy( isBuildingEnabled: Boolean = this.isBuildingEnabled, isIndoorEnabled: Boolean = this.isIndoorEnabled, isMyLocationEnabled: Boolean = this.isMyLocationEnabled, @@ -64,7 +78,7 @@ class MapProperties( mapType: MapType = this.mapType, maxZoomPreference: Float = this.maxZoomPreference, minZoomPreference: Float = this.minZoomPreference, - ) = MapProperties( + ): MapProperties = MapProperties( isBuildingEnabled = isBuildingEnabled, isIndoorEnabled = isIndoorEnabled, isMyLocationEnabled = isMyLocationEnabled, diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapType.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapType.kt index 9382e7f55..0d292faf2 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapType.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapType.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.Immutable * Enumerates the different types of map tiles. */ @Immutable -enum class MapType(val value: Int) { +public enum class MapType(public val value: Int) { NONE(com.google.android.gms.maps.GoogleMap.MAP_TYPE_NONE), NORMAL(com.google.android.gms.maps.GoogleMap.MAP_TYPE_NORMAL), SATELLITE(com.google.android.gms.maps.GoogleMap.MAP_TYPE_SATELLITE), diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapUiSettings.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapUiSettings.kt index 4299a124c..5f1870eae 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapUiSettings.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapUiSettings.kt @@ -25,17 +25,17 @@ internal val DefaultMapUiSettings = MapUiSettings() * compatibility on future changes. * See: https://jakewharton.com/public-api-challenges-in-kotlin/ */ -class MapUiSettings( - val compassEnabled: Boolean = true, - val indoorLevelPickerEnabled: Boolean = true, - val mapToolbarEnabled: Boolean = true, - val myLocationButtonEnabled: Boolean = true, - val rotationGesturesEnabled: Boolean = true, - val scrollGesturesEnabled: Boolean = true, - val scrollGesturesEnabledDuringRotateOrZoom: Boolean = true, - val tiltGesturesEnabled: Boolean = true, - val zoomControlsEnabled: Boolean = true, - val zoomGesturesEnabled: Boolean = true, +public class MapUiSettings( + public val compassEnabled: Boolean = true, + public val indoorLevelPickerEnabled: Boolean = true, + public val mapToolbarEnabled: Boolean = true, + public val myLocationButtonEnabled: Boolean = true, + public val rotationGesturesEnabled: Boolean = true, + public val scrollGesturesEnabled: Boolean = true, + public val scrollGesturesEnabledDuringRotateOrZoom: Boolean = true, + public val tiltGesturesEnabled: Boolean = true, + public val zoomControlsEnabled: Boolean = true, + public val zoomGesturesEnabled: Boolean = true, ) { override fun toString(): String = "MapUiSettings(" + "compassEnabled=$compassEnabled, indoorLevelPickerEnabled=$indoorLevelPickerEnabled, " + @@ -70,7 +70,7 @@ class MapUiSettings( zoomGesturesEnabled ) - fun copy( + public fun copy( compassEnabled: Boolean = this.compassEnabled, indoorLevelPickerEnabled: Boolean = this.indoorLevelPickerEnabled, mapToolbarEnabled: Boolean = this.mapToolbarEnabled, @@ -81,7 +81,7 @@ class MapUiSettings( tiltGesturesEnabled: Boolean = this.tiltGesturesEnabled, zoomControlsEnabled: Boolean = this.zoomControlsEnabled, zoomGesturesEnabled: Boolean = this.zoomGesturesEnabled - ) = MapUiSettings( + ): MapUiSettings = MapUiSettings( compassEnabled = compassEnabled, indoorLevelPickerEnabled = indoorLevelPickerEnabled, mapToolbarEnabled = mapToolbarEnabled, diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt index eb5942f53..dca9fb4ba 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt @@ -1,3 +1,17 @@ +// Copyright 2022 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 import android.annotation.SuppressLint @@ -80,6 +94,10 @@ internal class MapPropertiesNode( override fun onRemoved() { cameraPositionState.setMap(null) } + + override fun onCleared() { + cameraPositionState.setMap(null) + } } internal val NoPadding = PaddingValues() 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 722d22c95..e61c88fa5 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 @@ -21,8 +21,9 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.currentComposer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import com.google.android.gms.maps.model.BitmapDescriptor @@ -33,7 +34,7 @@ import com.google.maps.android.ktx.addMarker internal class MarkerNode( val compositionContext: CompositionContext, val marker: Marker, - var markerDragState: MarkerDragState?, + val markerState: MarkerState, var onMarkerClick: (Marker) -> Boolean, var onInfoWindowClick: (Marker) -> Unit, var onInfoWindowClose: (Marker) -> Unit, @@ -41,39 +42,92 @@ internal class MarkerNode( var infoWindow: (@Composable (Marker) -> Unit)?, var infoContent: (@Composable (Marker) -> Unit)?, ) : MapNode { + override fun onAttached() { + markerState.marker = marker + } override fun onRemoved() { + markerState.marker = null + marker.remove() + } + + override fun onCleared() { + markerState.marker = null marker.remove() } } @Immutable -enum class DragState { +public enum class DragState { START, DRAG, END } /** - * A state object for observing marker drag events. + * A state object that can be hoisted to control and observe the marker state. + * + * @param position the initial marker position */ -class MarkerDragState { +public class MarkerState( + position: LatLng = LatLng(0.0, 0.0) +) { /** - * State of the marker drag. + * Current position of the marker. */ - var dragState: DragState by mutableStateOf(DragState.END) + public var position: LatLng by mutableStateOf(position) + + /** + * Current [DragState] of the marker. + */ + public var dragState: DragState by mutableStateOf(DragState.END) internal set + + // The marker associated with this MarkerState. + internal var marker: Marker? = null + set(value) { + if (field == null && value == null) return + if (field != null && value != null) { + error("MarkerState may only be associated with one Marker at a time.") + } + field = value + } + + /** + * Shows the info window for the underlying marker + */ + public fun showInfoWindow() { + marker?.showInfoWindow() + } + + /** + * Hides the info window for the underlying marker + */ + public fun hideInfoWindow() { + marker?.hideInfoWindow() + } + + public companion object { + /** + * The default saver implementation for [MarkerState] + */ + public val Saver: Saver = Saver( + save = { it.position }, + restore = { MarkerState(it) } + ) + } } -/** - * Creates and [remember] a [MarkerDragState]. - */ @Composable -fun rememberMarkerDragState(): MarkerDragState = remember { - MarkerDragState() +public fun rememberMarkerState( + key: String? = null, + position: LatLng = LatLng(0.0, 0.0) +): MarkerState = rememberSaveable(key = key, saver = MarkerState.Saver) { + MarkerState(position) } /** * A composable for a marker on the map. * - * @param position the position of the marker + * @param state the [MarkerState] to be used to control or observe the marker + * state such as its position and info window * @param alpha the alpha (opacity) of the marker * @param anchor the anchor for the marker image * @param draggable sets the draggability for the marker @@ -86,15 +140,15 @@ fun rememberMarkerDragState(): MarkerDragState = remember { * @param title the title for the marker * @param visible the visibility of the marker * @param zIndex the z-index of the marker - * @param markerDragState a [MarkerDragState] to be used for observing marker drag events * @param onClick a lambda invoked when the marker is clicked * @param onInfoWindowClick a lambda invoked when the marker's info window is clicked * @param onInfoWindowClose a lambda invoked when the marker's info window is closed * @param onInfoWindowLongClick a lambda invoked when the marker's info window is long clicked */ @Composable -fun Marker( - position: LatLng, +@GoogleMapComposable +public fun Marker( + state: MarkerState = rememberMarkerState(), alpha: Float = 1.0f, anchor: Offset = Offset(0.5f, 1.0f), draggable: Boolean = false, @@ -107,14 +161,13 @@ fun Marker( title: String? = null, visible: Boolean = true, zIndex: Float = 0.0f, - markerDragState: MarkerDragState? = null, onClick: (Marker) -> Boolean = { false }, onInfoWindowClick: (Marker) -> Unit = {}, onInfoWindowClose: (Marker) -> Unit = {}, onInfoWindowLongClick: (Marker) -> Unit = {}, ) { MarkerImpl( - position = position, + state = state, alpha = alpha, anchor = anchor, draggable = draggable, @@ -127,7 +180,6 @@ fun Marker( title = title, visible = visible, zIndex = zIndex, - markerDragState = markerDragState, onClick = onClick, onInfoWindowClick = onInfoWindowClick, onInfoWindowClose = onInfoWindowClose, @@ -140,7 +192,8 @@ fun Marker( * customized. If this customization is not required, use * [com.google.maps.android.compose.Marker]. * - * @param position the position of the marker + * @param state the [MarkerState] to be used to control or observe the marker + * state such as its position and info window * @param alpha the alpha (opacity) of the marker * @param anchor the anchor for the marker image * @param draggable sets the draggability for the marker @@ -153,7 +206,6 @@ fun Marker( * @param title the title for the marker * @param visible the visibility of the marker * @param zIndex the z-index of the marker - * @param markerDragState a [MarkerDragState] to be used for observing marker drag events * @param onClick a lambda invoked when the marker is clicked * @param onInfoWindowClick a lambda invoked when the marker's info window is clicked * @param onInfoWindowClose a lambda invoked when the marker's info window is closed @@ -162,8 +214,9 @@ fun Marker( * info window's content */ @Composable -fun MarkerInfoWindow( - position: LatLng, +@GoogleMapComposable +public fun MarkerInfoWindow( + state: MarkerState = rememberMarkerState(), alpha: Float = 1.0f, anchor: Offset = Offset(0.5f, 1.0f), draggable: Boolean = false, @@ -176,7 +229,6 @@ fun MarkerInfoWindow( title: String? = null, visible: Boolean = true, zIndex: Float = 0.0f, - markerDragState: MarkerDragState? = null, onClick: (Marker) -> Boolean = { false }, onInfoWindowClick: (Marker) -> Unit = {}, onInfoWindowClose: (Marker) -> Unit = {}, @@ -184,7 +236,7 @@ fun MarkerInfoWindow( content: (@Composable (Marker) -> Unit)? = null ) { MarkerImpl( - position = position, + state = state, alpha = alpha, anchor = anchor, draggable = draggable, @@ -197,7 +249,6 @@ fun MarkerInfoWindow( title = title, visible = visible, zIndex = zIndex, - markerDragState = markerDragState, onClick = onClick, onInfoWindowClick = onInfoWindowClick, onInfoWindowClose = onInfoWindowClose, @@ -211,7 +262,8 @@ fun MarkerInfoWindow( * customized. If this customization is not required, use * [com.google.maps.android.compose.Marker]. * - * @param position the position of the marker + * @param state the [MarkerState] to be used to control or observe the marker + * state such as its position and info window * @param alpha the alpha (opacity) of the marker * @param anchor the anchor for the marker image * @param draggable sets the draggability for the marker @@ -224,7 +276,6 @@ fun MarkerInfoWindow( * @param title the title for the marker * @param visible the visibility of the marker * @param zIndex the z-index of the marker - * @param markerDragState a [MarkerDragState] to be used for observing marker drag events * @param onClick a lambda invoked when the marker is clicked * @param onInfoWindowClick a lambda invoked when the marker's info window is clicked * @param onInfoWindowClose a lambda invoked when the marker's info window is closed @@ -233,8 +284,9 @@ fun MarkerInfoWindow( * info window's content */ @Composable -fun MarkerInfoWindowContent( - position: LatLng, +@GoogleMapComposable +public fun MarkerInfoWindowContent( + state: MarkerState = rememberMarkerState(), alpha: Float = 1.0f, anchor: Offset = Offset(0.5f, 1.0f), draggable: Boolean = false, @@ -247,7 +299,6 @@ fun MarkerInfoWindowContent( title: String? = null, visible: Boolean = true, zIndex: Float = 0.0f, - markerDragState: MarkerDragState? = null, onClick: (Marker) -> Boolean = { false }, onInfoWindowClick: (Marker) -> Unit = {}, onInfoWindowClose: (Marker) -> Unit = {}, @@ -255,7 +306,7 @@ fun MarkerInfoWindowContent( content: (@Composable (Marker) -> Unit)? = null ) { MarkerImpl( - position = position, + state = state, alpha = alpha, anchor = anchor, draggable = draggable, @@ -268,7 +319,6 @@ fun MarkerInfoWindowContent( title = title, visible = visible, zIndex = zIndex, - markerDragState = markerDragState, onClick = onClick, onInfoWindowClick = onInfoWindowClick, onInfoWindowClose = onInfoWindowClose, @@ -280,7 +330,8 @@ fun MarkerInfoWindowContent( /** * Internal implementation for a marker on a Google map. * - * @param position the position of the marker + * @param state the [MarkerState] to be used to control or observe the marker + * state such as its position and info window * @param alpha the alpha (opacity) of the marker * @param anchor the anchor for the marker image * @param draggable sets the draggability for the marker @@ -293,7 +344,6 @@ fun MarkerInfoWindowContent( * @param title the title for the marker * @param visible the visibility of the marker * @param zIndex the z-index of the marker - * @param markerDragState a [MarkerDragState] to be used for observing marker drag events * @param onClick a lambda invoked when the marker is clicked * @param onInfoWindowClick a lambda invoked when the marker's info window is clicked * @param onInfoWindowClose a lambda invoked when the marker's info window is closed @@ -305,8 +355,9 @@ fun MarkerInfoWindowContent( * the info window's content. If this value is non-null, [infoWindow] must be null. */ @Composable +@GoogleMapComposable private fun MarkerImpl( - position: LatLng, + state: MarkerState = rememberMarkerState(), alpha: Float = 1.0f, anchor: Offset = Offset(0.5f, 1.0f), draggable: Boolean = false, @@ -319,7 +370,6 @@ private fun MarkerImpl( title: String? = null, visible: Boolean = true, zIndex: Float = 0.0f, - markerDragState: MarkerDragState? = null, onClick: (Marker) -> Boolean = { false }, onInfoWindowClick: (Marker) -> Unit = {}, onInfoWindowClose: (Marker) -> Unit = {}, @@ -338,7 +388,7 @@ private fun MarkerImpl( flat(flat) icon(icon) infoWindowAnchor(infoWindowAnchor.x, infoWindowAnchor.y) - position(position) + position(state.position) rotation(rotation) snippet(snippet) title(title) @@ -349,7 +399,7 @@ private fun MarkerImpl( MarkerNode( compositionContext = compositionContext, marker = marker, - markerDragState = markerDragState, + markerState = state, onMarkerClick = onClick, onInfoWindowClick = onInfoWindowClick, onInfoWindowClose = onInfoWindowClose, @@ -359,7 +409,6 @@ private fun MarkerImpl( ) }, update = { - update(markerDragState) { this.markerDragState = it } update(onClick) { this.onMarkerClick = it } update(onInfoWindowClick) { this.onInfoWindowClick = it } update(onInfoWindowClose) { this.onInfoWindowClose = it } @@ -373,7 +422,7 @@ private fun MarkerImpl( set(flat) { this.marker.isFlat = it } set(icon) { this.marker.setIcon(it) } set(infoWindowAnchor) { this.marker.setInfoWindowAnchor(it.x, it.y) } - set(position) { this.marker.position = it } + set(state.position) { this.marker.position = it } set(rotation) { this.marker.rotation = it } set(snippet) { this.marker.snippet = it diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/Polygon.kt b/maps-compose/src/main/java/com/google/maps/android/compose/Polygon.kt index aa0d53aba..368cd5e01 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/Polygon.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/Polygon.kt @@ -52,7 +52,8 @@ internal class PolygonNode( * @param onClick a lambda invoked when the polygon is clicked */ @Composable -fun Polygon( +@GoogleMapComposable +public fun Polygon( points: List, clickable: Boolean = false, fillColor: Color = Color.Black, diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/Polyline.kt b/maps-compose/src/main/java/com/google/maps/android/compose/Polyline.kt index 7eca6b27a..242f0862e 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/Polyline.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/Polyline.kt @@ -54,7 +54,8 @@ internal class PolylineNode( * @param onClick a lambda invoked when the polyline is clicked */ @Composable -fun Polyline( +@GoogleMapComposable +public fun Polyline( points: List, clickable: Boolean = false, color: Color = Color.Black, diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt b/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt index 0f1e4f2c5..462018923 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/TileOverlay.kt @@ -41,7 +41,8 @@ private class TileOverlayNode( * @param onClick a lambda invoked when the tile overlay is clicked */ @Composable -fun TileOverlay( +@GoogleMapComposable +public fun TileOverlay( tileProvider: TileProvider, fadeIn: Boolean = true, transparency: Float = 0f,