diff --git a/.github/workflows/multiplatform.yml b/.github/workflows/multiplatform.yml new file mode 100644 index 000000000..0954dd038 --- /dev/null +++ b/.github/workflows/multiplatform.yml @@ -0,0 +1,72 @@ +# Copyright 2026 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. + +# Builds and tests the iOS side of maps-compose-multiplatform and the iOS demo app. The Android +# side is covered by the other workflows; iOS can only be built on macOS. +name: Multiplatform (iOS) + +on: + pull_request: + branches-ignore: ['gh-pages'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + ios: + runs-on: macos-latest + timeout-minutes: 60 + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + + # Compiles both iOS targets, runs the iOS simulator tests, checks the public API of every + # target against api/, and links the framework the iOS app embeds. + - name: Build and test the iOS targets + run: >- + ./gradlew --stacktrace + :maps-compose-multiplatform:iosSimulatorArm64Test + :maps-compose-multiplatform:checkKotlinAbi + :maps-compose-multiplatform:linkPodDebugFrameworkIosSimulatorArm64 + :maps-compose-multiplatform:linkPodDebugFrameworkIosArm64 + :maps-compose-multiplatform-demo:compileKotlinIosArm64 + + # The app reads its key from secrets.properties at build time and falls back to a + # placeholder, which is enough to compile. + - name: Build the iOS demo app + working-directory: iosApp + run: | + (cd .. && ./gradlew :maps-compose-multiplatform-demo:generateDummyFramework) + ruby create_project.rb + pod install + xcodebuild build \ + -workspace iosApp.xcworkspace \ + -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + ARCHS=arm64 \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ffeb58737..94c5eecf7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,7 +23,8 @@ permissions: jobs: publish: - runs-on: ubuntu-latest + # maps-compose-multiplatform publishes iOS klibs, which can only be built on macOS. + runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -45,12 +46,12 @@ jobs: gpg --quiet --output $GITHUB_WORKSPACE/release.gpg --dearmor ./release.asc echo "Build and publish" - sed -i -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties + sed -i.bak -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties SONATYPE_TOKEN_PASSWORD_ESCAPED=$(printf '%s\n' "$SONATYPE_TOKEN_PASSWORD" | sed -e 's/[\/&]/\\&/g') - sed -i -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties - sed -i -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties - sed -i -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties - sed -i -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties + sed -i.bak -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties + sed -i.bak -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties + sed -i.bak -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties + sed -i.bak -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties env: GPG_KEY_ARMOR: ${{ secrets.SYNCED_GPG_KEY_ARMOR }} diff --git a/.gitignore b/.gitignore index a77c626aa..a7baf6c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ secrets.properties # This covers new IDEs, like Antigravity .vscode/ +.antigravitycli/ build-logic/**/bin/ diff --git a/AGENTS.md b/AGENTS.md index 4a58c7c10..cbccc1a0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,10 @@ libraries live in three modules; the rest of the repo supports them. | `maps-compose` | Core library: `GoogleMap` composable, camera state, markers, shapes | | `maps-compose-utils` | Utilities layer: clustering and other android-maps-utils integrations | | `maps-compose-widgets` | Widget composables built on top of the core library | +| `maps-compose-multiplatform` | Experimental Compose Multiplatform map for Android and iOS, see below | +| `maps-compose-multiplatform-demo` | Demo screens shared by `maps-app` and `iosApp`; not published | | `maps-app` | Demo app exercising the libraries | +| `iosApp` | iOS demo app for `maps-compose-multiplatform`, see `iosApp/README.md` | | `docs` | Dokka documentation aggregation | Shared Gradle conventions are in `build-logic/` (included build). @@ -28,6 +31,35 @@ Shared Gradle conventions are in `build-logic/` (included build). ./gradlew :maps-app:validateDebugScreenshotTest # validate screenshot tests ``` +### Kotlin Multiplatform (experimental) + +`maps-compose-multiplatform` targets Android (wrapping `maps-compose`) and iOS (wrapping the +Google Maps SDK for iOS through CocoaPods). Its iOS targets only build on macOS, which is why +`.github/workflows/multiplatform.yml` and the publish workflow run on macOS runners. + +```bash +./gradlew :maps-compose-multiplatform:allTests # commonTest on the JVM and the iOS simulator +./gradlew :maps-compose-multiplatform:checkKotlinAbi # public API of every target against api/ +./gradlew :maps-compose-multiplatform:updateKotlinAbi # after an intended public API change +``` + +- Shared code goes in `commonMain`; only platform glue goes in `androidMain` and `iosMain`. + Tests go in `commonTest` so they run on both platforms. +- The public API is checked for each target: the iOS klibs in `api/*.klib.api` and the Android + target in `api/maps-compose-multiplatform.api`. Run `updateKotlinAbi` and commit the result + with any intended API change. +- Clustering and the shared `LatLng` come from the multiplatform modules of + android-maps-utils (`mapsutilsKmp` in the version catalog). While that is a `-SNAPSHOT`, + publishing a release of this module fails on purpose. +- There is no `iosX64` target: Compose Multiplatform no longer publishes it. +- Map content (`Marker`, `Polyline`, ...) does not emit UI. Each call registers a node in the + map's `MapNodeRegistry` while it is in the composition; `androidMain` draws the nodes with + maps-compose, `iosMain` keeps one `GMSOverlay` per node and updates it in place. A new kind of + content needs a node class in `MapContent.kt` and a case on both platforms. +- Demos go in `maps-compose-multiplatform-demo` so both apps show them. Open one directly with + `adb shell am start -n com.google.maps.android.compose/.KmpMapActivity --ei demo ` or + `xcrun simctl launch booted com.google.maps.android.compose.iosApp -demo `. + Coverage is tracked over time. After every merge to `main`, the `Record coverage history` workflow appends a row per module per suite to `history.csv` and regenerates `COVERAGE.md`, both of which live on the diff --git a/README.md b/README.md index 74e31060c..0164d1098 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,54 @@ Box(Modifier.fillMaxSize()) { The colors of the text, line, and shadow are also all configurable (e.g., based on `isSystemInDarkTheme()` on a dark map). Similarly, the `DisappearingScaleBar` animations can be configured. +## Kotlin Multiplatform (experimental) + +`maps-compose-multiplatform` brings the same `GoogleMap` composable to Compose Multiplatform apps +that target Android and iOS. On Android it is built on `maps-compose`; on iOS it shows the +[Google Maps SDK for iOS](https://developers.google.com/maps/documentation/ios-sdk). Its API +follows `maps-compose`, so code moves between the two with few changes. + +It is experimental: the API may change in any release, and it covers a subset of `maps-compose`: + +| Supported | Not yet | +| --- | --- | +| `GoogleMap` with `CameraPositionState`, `MapProperties` and `MapUiSettings` | Custom marker icons and info window content | +| Map clicks, long clicks and `onMapLoaded` | Ground and tile overlays, heatmaps | +| `Marker` (draggable, tappable, tinted pin), `Polyline`, `Polygon` with holes, `Circle` | Street View | +| `Clustering` that reclusters as the camera zooms | `MapEffect` and direct access to the native map | + +```kotlin +// commonMain +val sydney = LatLng(-33.8688, 151.2093) +val cameraPositionState = rememberCameraPositionState { + position = cameraPosition(sydney, zoom = 12f) +} +GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + onMapClick = { println("Tapped $it") }, +) { + Marker(state = rememberMarkerState(sydney), title = "Sydney") + Circle(center = sydney, radius = 1_000.0, strokeColor = Color.Red) + Clustering(items = places) +} +``` + +`LatLng` and `CameraPosition` come from `com.google.maps.android.model`, shared with the multiplatform +modules of the [Maps SDK for Android Utility Library](https://github.com/googlemaps/android-maps-utils). +On Android they are the Maps SDK's own classes. Line widths are in density-independent pixels, so +shapes look the same on both platforms. + +On iOS, add the `GoogleMaps` CocoaPod (10.14.0 or later, iOS 16 or later) and provide your API +key before showing a map: + +```swift +GMSServices.provideAPIKey("YOUR_API_KEY") +``` + +The [`iosApp`](iosApp/README.md) and the "Multiplatform demos" entry of the Android sample app run +the same demos, from [`maps-compose-multiplatform-demo`](maps-compose-multiplatform-demo). + ## Internal usage attribution ID This library calls the `addInternalUsageAttributionId` method, which helps Google understand which libraries and samples are helpful to developers and is optional. Instructions for opting out of the identifier are provided below. diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 7b0a0d808..650e5266b 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.dokka.plugin) implementation(libs.kover.gradle.plugin) implementation(libs.gradle.maven.publish.plugin) + implementation(libs.binary.compatibility.validator) } @@ -40,5 +41,9 @@ gradlePlugin { id = "android.maps.compose.PublishingConventionPlugin" implementationClass = "PublishingConventionPlugin" } + register("kmpPublishingConventionPlugin") { + id = "android.maps.compose.KmpPublishingConventionPlugin" + implementationClass = "KmpPublishingConventionPlugin" + } } } \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/KmpPublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpPublishingConventionPlugin.kt new file mode 100644 index 000000000..36e380df6 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpPublishingConventionPlugin.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2026 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. + */ + +import com.android.build.api.dsl.Lint +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinMultiplatform +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SourcesJar +import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension +import kotlinx.validation.KotlinApiBuildTask +import kotlinx.validation.KotlinApiCompareTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.register +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation + +/** + * Conventions for the Kotlin Multiplatform libraries in this repository: Android and iOS targets, + * explicit API mode, ABI validation of every target, lint, coverage and Maven Central publishing. + * + * iOS klibs can only be built on macOS, so publishing a module that applies this plugin needs a + * macOS runner. + */ +class KmpPublishingConventionPlugin : Plugin { + override fun apply(project: Project) { + project.run { + apply(plugin = "org.jetbrains.kotlin.multiplatform") + apply(plugin = "com.android.kotlin.multiplatform.library") + // Multiplatform Android libraries only get lint tasks from the standalone lint plugin. + apply(plugin = "com.android.lint") + apply(plugin = "org.jetbrains.kotlinx.kover") + apply(plugin = "org.jetbrains.dokka") + apply(plugin = "com.vanniktech.maven.publish") + + configure { + explicitApi() + // No iosX64 (Intel simulators): Compose Multiplatform dropped it in 1.11. + iosArm64() + iosSimulatorArm64() + + // Records the public API of the iOS klibs in api/. The Android target is not + // supported yet, see configureAndroidApiValidation. Run updateKotlinAbi after an + // intended API change; checkKotlinAbi runs with check. + @OptIn(ExperimentalAbiValidation::class) + abiValidation() + } + + configure { + sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile + } + + configure { + // CI and the coverage history read koverXmlReportDebug / reportDebug.xml, which + // the Android-only modules produce. Expose the KMP Android coverage the same way. + currentProject { + createVariant("debug") { + add("android") + } + } + reports { + filters { + excludes { + androidGeneratedClasses() + } + } + } + } + + configure { + configure( + KotlinMultiplatform( + javadocJar = JavadocJar.Dokka("dokkaGeneratePublicationHtml"), + sourcesJar = SourcesJar.Sources(), + ) + ) + configureMapsComposePublishing( + project, + pomDescription = "Compose Multiplatform components for the Maps SDKs for Android and iOS", + ) + } + + configureAndroidApiValidation() + } + } + + /** + * Kotlin's ABI validation does not cover the Android target of a multiplatform library, so + * dump its public API from the compiled classes jar with the binary-compatibility-validator + * tasks, into api/.api, and hook them into updateKotlinAbi and checkKotlinAbi. + */ + private fun Project.configureAndroidApiValidation() { + val projectName = name + val apiFile = layout.projectDirectory.file("api/$projectName.api") + val buildApiFile = layout.buildDirectory.file("api/android/$projectName.api") + + afterEvaluate { + val bundleTask = tasks.findByName("bundleAndroidMainClassesToCompileJar") ?: return@afterEvaluate + val classesJar = layout.buildDirectory.file( + "intermediates/compile_library_classes_jar/androidMain/bundleAndroidMainClassesToCompileJar/classes.jar" + ) + + val androidApiBuild = tasks.register("androidApiBuild") { + group = "verification" + description = "Builds the public Android API declaration for $projectName." + inputJar.set(classesJar) + outputApiFile.set(buildApiFile) + dependsOn(bundleTask) + } + + // Writes only its own file: a Copy into api/ would claim the directory that the klib + // dump also writes to. + val androidApiDump = tasks.register("androidApiDump") { + group = "verification" + description = "Syncs the public Android API declaration of $projectName to api/$projectName.api." + val generated = androidApiBuild.flatMap { it.outputApiFile } + inputs.file(generated) + outputs.file(apiFile) + doLast { + generated.get().asFile.copyTo(apiFile.asFile, overwrite = true) + } + } + + val androidApiCheck = tasks.register("androidApiCheck") { + group = "verification" + description = "Checks the public Android API of $projectName against api/$projectName.api." + projectApiFile.set(apiFile) + generatedApiFile.set(androidApiBuild.flatMap { it.outputApiFile }) + dependsOn(androidApiBuild) + mustRunAfter(androidApiDump) + } + + tasks.named("updateKotlinAbi") { dependsOn(androidApiDump) } + tasks.named("checkKotlinAbi") { dependsOn(androidApiCheck) } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/MapsComposePublishing.kt b/build-logic/convention/src/main/kotlin/MapsComposePublishing.kt new file mode 100644 index 000000000..53f300f82 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/MapsComposePublishing.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 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. + */ + +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import org.gradle.api.Project + +/** + * Publishes to Maven Central with signing and the POM shared by every library in this + * repository, Android-only and multiplatform alike. + */ +fun MavenPublishBaseExtension.configureMapsComposePublishing( + project: Project, + pomDescription: String = "Jetpack Compose components for the Maps SDK for Android", +) { + publishToMavenCentral() + signAllPublications() + + pom { + name.set(project.name) + description.set(pomDescription) + url.set("https://github.com/googlemaps/android-maps-compose") + licenses { + license { + name.set("The Apache Software License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + scm { + connection.set("scm:git@github.com:googlemaps/android-maps-compose.git") + developerConnection.set("scm:git@github.com:googlemaps/android-maps-compose.git") + url.set("https://github.com/googlemaps/android-maps-compose") + } + developers { + developer { + id.set("google") + name.set("Google Inc.") + } + } + organization { + name.set("Google Inc") + url.set("http://developers.google.com/maps") + } + } +} diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 388b8db8e..bb9cc9dff 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -61,36 +61,7 @@ class PublishingConventionPlugin : Plugin { ) ) - publishToMavenCentral() - signAllPublications() - - pom { - name.set(project.name) - description.set("Jetpack Compose components for the Maps SDK for Android") - url.set("https://github.com/googlemaps/android-maps-compose") - licenses { - license { - name.set("The Apache Software License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - distribution.set("repo") - } - } - scm { - connection.set("scm:git@github.com:googlemaps/android-maps-compose.git") - developerConnection.set("scm:git@github.com:googlemaps/android-maps-compose.git") - url.set("https://github.com/googlemaps/android-maps-compose") - } - developers { - developer { - id.set("google") - name.set("Google Inc.") - } - } - organization { - name.set("Google Inc") - url.set("http://developers.google.com/maps") - } - } + configureMapsComposePublishing(project) } } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94c1adcc0..6ebe9cbad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ androidTargetSdk = "37" agp = "9.4.0" dependencyAnalysis = "3.19.1" dokka = "2.2.0" +binaryCompatibilityValidator = "0.18.2" gradleMavenPublishPlugin = "0.37.0" kotlin = "2.4.10" kotlinxCoroutines = "1.11.0" @@ -30,6 +31,9 @@ materialIconsExtendedAndroid = "1.7.8" # Google Maps Platform mapsecrets = "2.0.1" mapsutils = "6.0.0" +# Multiplatform modules from the android-maps-utils KMP branch, published as snapshots. +mapsutilsKmp = "6.1.0-SNAPSHOT" +composeMultiplatform = "1.12.1" # Testing androidCore = "1.7.0" @@ -47,9 +51,11 @@ truth = "1.4.5" [libraries] # Base android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +binary-compatibility-validator = { module = "org.jetbrains.kotlinx.binary-compatibility-validator:org.jetbrains.kotlinx.binary-compatibility-validator.gradle.plugin", version.ref = "binaryCompatibilityValidator" } dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } kotlin = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } @@ -72,6 +78,12 @@ material = { group = "com.google.android.material", name = "material", version.r # Google Maps Platform maps-utils-core = { module = "com.google.maps.android:android-maps-utils-core", version.ref = "mapsutils" } +compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +maps-utils-kmp-core = { module = "com.google.maps.android:android-maps-utils-core", version.ref = "mapsutilsKmp" } +maps-utils-kmp-clustering = { module = "com.google.maps.android:android-maps-utils-clustering", version.ref = "mapsutilsKmp" } +maps-utils-kmp-maps-model = { module = "com.google.maps.android:android-maps-utils-maps-model", version.ref = "mapsutilsKmp" } maps-utils = { module = "com.google.maps.android:android-maps-utils", version.ref = "mapsutils" } maps-secrets-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "mapsecrets" } diff --git a/iosApp/.gitignore b/iosApp/.gitignore new file mode 100644 index 000000000..e1d48ef7b --- /dev/null +++ b/iosApp/.gitignore @@ -0,0 +1,22 @@ +# CocoaPods +Pods/ +Podfile.lock + +# Xcode +build/ +DerivedData/ +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +xcuserdata/ +*.xcworkspace +!default.xcworkspace +*.xcuserstate + +# Secrets +iosApp/DeveloperSecrets.swift + + +# Generated by create_project.rb +iosApp.xcodeproj/ diff --git a/iosApp/Podfile b/iosApp/Podfile new file mode 100644 index 000000000..0e926f5ad --- /dev/null +++ b/iosApp/Podfile @@ -0,0 +1,20 @@ +platform :ios, '16.0' + +# Ignore warnings from CocoaPods dependencies +inhibit_all_warnings! + +target 'iosApp' do + use_frameworks! + + # The shared demo screens, which bring in maps-compose-multiplatform and the GoogleMaps pod. + pod 'maps_compose_multiplatform_demo', :path => '../maps-compose-multiplatform-demo' +end + +# Match the app: the Kotlin framework has no iosX64 target, so pods skip Intel simulators too. +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64' + end + end +end diff --git a/iosApp/README.md b/iosApp/README.md new file mode 100644 index 000000000..570409c3c --- /dev/null +++ b/iosApp/README.md @@ -0,0 +1,58 @@ +# iOS demo app + +Demos of `maps-compose-multiplatform` on iOS. The screens come from +`maps-compose-multiplatform-demo`, the same ones the "Multiplatform demos" entry of `maps-app` +shows; this app only hosts them. + +It needs a Mac with Xcode, CocoaPods and the `xcodeproj` gem (installed with CocoaPods). + +## Run it + +1. Put your Maps API key in `secrets.properties` at the repository root, as for the Android app: + + ```properties + MAPS_API_KEY=YOUR_API_KEY + ``` + + A build phase copies it into `iosApp/DeveloperSecrets.swift`, which is ignored by git. Without + a key the app still builds, but the maps stay blank. + +2. From the repository root, create the placeholder framework CocoaPods links against: + + ```bash + ./gradlew :maps-compose-multiplatform-demo:generateDummyFramework + ``` + +3. Generate the Xcode project and install the pods: + + ```bash + cd iosApp + ruby create_project.rb + pod install + ``` + + The project is generated, not checked in. Run `create_project.rb` again after pulling changes + to it. + +4. Open `iosApp.xcworkspace` (not the `.xcodeproj`), select the `iosApp` scheme and an iOS + simulator, and run. Xcode builds the Kotlin framework through Gradle as part of the build. + +To open a demo directly, pass its index in the demo list: + +```bash +xcrun simctl launch booted com.google.maps.android.compose.iosApp -demo 3 +``` + +To build from the command line, as CI does: + +```bash +xcodebuild build -workspace iosApp.xcworkspace -scheme iosApp \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO +``` + +## Troubleshooting + +- **`Build input file cannot be found: DeveloperSecrets.swift`**: the project predates the + secrets build phase output. Run `ruby create_project.rb` again. +- **Intel Macs**: only Apple silicon simulators are supported. Compose Multiplatform no longer + publishes the `iosX64` target. diff --git a/iosApp/create_project.rb b/iosApp/create_project.rb new file mode 100644 index 000000000..71a224e0f --- /dev/null +++ b/iosApp/create_project.rb @@ -0,0 +1,76 @@ +require 'xcodeproj' + +# Initialize project +project_path = 'iosApp.xcodeproj' +project = Xcodeproj::Project.new(project_path) + +# Set deployment target to iOS 16.0 project-wide +project.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' +end + +# Create group for source files (maps to the iosApp directory) +group = project.main_group.new_group('iosApp', 'iosApp') + +# Reference source files inside the group +app_delegate_ref = group.new_file('AppDelegate.swift') +secrets_ref = group.new_file('DeveloperSecrets.swift') +info_plist_ref = group.new_file('Info.plist') + +# Create target +target = project.new_target(:application, 'iosApp', :ios, '16.0') + +# Configure target settings +target.build_configurations.each do |config| + config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.google.maps.android.compose.iosApp' + config.build_settings['INFOPLIST_FILE'] = 'iosApp/Info.plist' + config.build_settings['SWIFT_VERSION'] = '5.0' + config.build_settings['SDKROOT'] = 'iphoneos' + config.build_settings['TARGETED_DEVICE_FAMILY'] = '1,2' + config.build_settings['CODE_SIGNING_REQUIRED'] = 'NO' + config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' + config.build_settings['AD_HOC_CODE_SIGNING_ALLOWED'] = 'YES' + # The Kotlin framework has no iosX64 target (Compose Multiplatform dropped it), so only build + # Apple silicon simulators. + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64' +end + +# Add a Build Phase Run Script to populate secrets before compilation +populate_secrets_phase = target.new_shell_script_build_phase('Populate Secrets') +populate_secrets_phase.output_paths = ['$(SRCROOT)/iosApp/DeveloperSecrets.swift'] +populate_secrets_phase.shell_script = <<-SHELL +SECRETS_PATH="${PROJECT_DIR}/../secrets.properties" +OUTPUT_FILE="${SRCROOT}/iosApp/DeveloperSecrets.swift" + +API_KEY="YOUR_API_KEY" + +if [ -f "$SECRETS_PATH" ]; then + EXTRACTED_KEY=$(grep -E "^IOS_MAPS_API_KEY=" "$SECRETS_PATH" | cut -d'=' -f2 | tr -d '"' | tr -d "'") + if [ -z "$EXTRACTED_KEY" ]; then + EXTRACTED_KEY=$(grep -E "^MAPS_API_KEY=" "$SECRETS_PATH" | cut -d'=' -f2 | tr -d '"' | tr -d "'") + fi + if [ ! -z "$EXTRACTED_KEY" ]; then + API_KEY="$EXTRACTED_KEY" + fi +fi + +cat < "$OUTPUT_FILE" +// Generated file. Do not commit or modify. +struct DeveloperSecrets { + static let mapsApiKey = "$API_KEY" +} +EOF +SHELL + +# Move the secrets phase to the very beginning of target build phases +target.build_phases.delete(populate_secrets_phase) +target.build_phases.insert(0, populate_secrets_phase) + +# Add files to their respective build phases +source_build_phase = target.source_build_phase +source_build_phase.add_file_reference(app_delegate_ref) +source_build_phase.add_file_reference(secrets_ref) + +project.save +puts "Xcode project created successfully!" + diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift new file mode 100644 index 000000000..c37d03aba --- /dev/null +++ b/iosApp/iosApp/AppDelegate.swift @@ -0,0 +1,23 @@ +import UIKit +import GoogleMaps +import maps_compose_multiplatform_demo + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + // Initialize Google Maps SDK dynamically. + GMSServices.provideAPIKey(DeveloperSecrets.mapsApiKey) + + window = UIWindow(frame: UIScreen.main.bounds) + // The demos are shared with the Android demo app, see maps-compose-multiplatform-demo. + window?.rootViewController = MainViewControllerKt.MainViewController() + window?.makeKeyAndVisible() + + return true + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 000000000..cd135cb65 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSLocationWhenInUseUsageDescription + Demonstrates multiplatform LocationSource Flow on Google Maps. + LSRequiresIPhoneOS + + UILaunchScreen + + CADisableMinimumFrameDurationOnPhone + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/maps-app/build.gradle.kts b/maps-app/build.gradle.kts index f638da4c8..c6a4142d3 100644 --- a/maps-app/build.gradle.kts +++ b/maps-app/build.gradle.kts @@ -133,6 +133,7 @@ dependencies { implementation(project(":maps-compose")) implementation(project(":maps-compose-widgets")) implementation(project(":maps-compose-utils")) + implementation(project(":maps-compose-multiplatform-demo")) } secrets { diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index d95e8c0ae..8f8b2f8e6 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ + + + diff --git a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt index b814f27de..77608b77c 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt @@ -87,6 +87,11 @@ sealed class ActivityGroup( R.string.street_view_activity_description, StreetViewActivity::class ), + Activity( + R.string.kmp_map_activity, + R.string.kmp_map_activity_description, + KmpMapActivity::class + ), ) ) diff --git a/maps-app/src/main/java/com/google/maps/android/compose/KmpMapActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/KmpMapActivity.kt new file mode 100644 index 000000000..a0e92f031 --- /dev/null +++ b/maps-app/src/main/java/com/google/maps/android/compose/KmpMapActivity.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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 androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.google.maps.android.compose.multiplatform.demo.MultiplatformDemoApp + +/** + * The maps-compose-multiplatform demos, the same screens the iOS demo app shows. Opens on the + * first demo, so that this entry shows a map like every other entry of the demo app, or on the + * demo at the `demo` extra, for example + * `adb shell am start -n com.google.maps.android.compose/.KmpMapActivity --ei demo 3`. + */ +class KmpMapActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MultiplatformDemoApp(startDemo = intent.getIntExtra("demo", 0)) + } + } +} diff --git a/maps-app/src/main/res/values/strings.xml b/maps-app/src/main/res/values/strings.xml index e0065b85f..1561f4418 100644 --- a/maps-app/src/main/res/values/strings.xml +++ b/maps-app/src/main/res/values/strings.xml @@ -84,6 +84,9 @@ Ground Overlay Adding a ground overlay to the map. + Multiplatform demos + The maps-compose-multiplatform demos, shared with the iOS demo app. + Map Types Map Features diff --git a/maps-compose-multiplatform-demo/build.gradle.kts b/maps-compose-multiplatform-demo/build.gradle.kts new file mode 100644 index 000000000..549f03992 --- /dev/null +++ b/maps-compose-multiplatform-demo/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Copyright 2026 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. + */ + +// Demo screens for maps-compose-multiplatform, shared by the Android demo app (maps-app) and the +// iOS demo app (iosApp). Not published. +plugins { + kotlin("multiplatform") + kotlin("native.cocoapods") + id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.compose.compiler) +} + +kotlin { + android { + namespace = "com.google.maps.android.compose.multiplatform.demo" + compileSdk = libs.versions.androidCompileSdk.get().toInt() + minSdk = libs.versions.androidMinSdk.get().toInt() + } + + iosArm64() + iosSimulatorArm64() + + cocoapods { + summary = "Demos of maps-compose-multiplatform" + homepage = "https://github.com/googlemaps/android-maps-compose" + version = project.version.toString() + ios.deploymentTarget = "16.0" + pod("GoogleMaps") { + version = "10.14.0.0" + } + framework { + baseName = "maps_compose_multiplatform_demo" + isStatic = true + } + } + + sourceSets { + commonMain { + dependencies { + implementation(project(":maps-compose-multiplatform")) + implementation(libs.compose.multiplatform.runtime) + implementation(libs.compose.multiplatform.foundation) + implementation(libs.compose.multiplatform.ui) + } + } + } +} diff --git a/maps-compose-multiplatform-demo/maps_compose_multiplatform_demo.podspec b/maps-compose-multiplatform-demo/maps_compose_multiplatform_demo.podspec new file mode 100644 index 000000000..69c589fb2 --- /dev/null +++ b/maps-compose-multiplatform-demo/maps_compose_multiplatform_demo.podspec @@ -0,0 +1,46 @@ +Pod::Spec.new do |spec| + spec.name = 'maps_compose_multiplatform_demo' + spec.version = '9.0.0' + spec.homepage = 'https://github.com/googlemaps/android-maps-compose' + spec.source = { :http=> ''} + spec.authors = '' + spec.license = '' + spec.summary = 'Demos of maps-compose-multiplatform' + spec.vendored_frameworks = 'build/cocoapods/framework/maps_compose_multiplatform_demo.framework' + spec.libraries = 'c++' + spec.ios.deployment_target = '16.0' + spec.dependency 'GoogleMaps', '10.14.0.0' + if !Dir.exist?('build/cocoapods/framework/maps_compose_multiplatform_demo.framework') || Dir.empty?('build/cocoapods/framework/maps_compose_multiplatform_demo.framework') + raise " + Kotlin framework 'maps_compose_multiplatform_demo' doesn't exist yet, so a proper Xcode project can't be generated. + 'pod install' should be executed after running ':generateDummyFramework' Gradle task: + ./gradlew :maps-compose-multiplatform-demo:generateDummyFramework + Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)" + end + spec.xcconfig = { + 'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO', + } + spec.pod_target_xcconfig = { + 'KOTLIN_PROJECT_PATH' => ':maps-compose-multiplatform-demo', + 'PRODUCT_MODULE_NAME' => 'maps_compose_multiplatform_demo', + } + spec.script_phases = [ + { + :name => 'Build maps_compose_multiplatform_demo', + :execution_position => :before_compile, + :shell_path => '/bin/sh', + :script => <<-SCRIPT + if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then + echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" + exit 0 + fi + set -ev + REPO_ROOT="$PODS_TARGET_SRCROOT" + "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \ + -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \ + -Pkotlin.native.cocoapods.archs="$ARCHS" \ + -Pkotlin.native.cocoapods.configuration="$CONFIGURATION" + SCRIPT + } + ] +end diff --git a/maps-compose-multiplatform-demo/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.android.kt b/maps-compose-multiplatform-demo/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.android.kt new file mode 100644 index 000000000..4259ed5a2 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.android.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2026 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.multiplatform.demo + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import androidx.compose.runtime.Composable +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.ui.platform.LocalContext +import com.google.maps.android.location.LocationPriority +import com.google.maps.android.location.LocationSource +import com.google.maps.android.location.asLocationSource +import com.google.maps.android.location.coarseLocationEvents +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.merge + +private const val LOCATION_PERMISSION_REQUEST_CODE = 1001 + +private fun Context.hasAnyLocationPermission(): Boolean = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + +private fun Context.hasFineLocationPermission(): Boolean = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +@SuppressLint("MissingPermission") +@Composable +internal actual fun rememberPlatformLocationController(): PlatformLocationController { + val context = LocalContext.current + val appContext = context.applicationContext + val activity = remember(context) { context.findActivity() } + var hasPermission by remember { mutableStateOf(appContext.hasAnyLocationPermission()) } + + // Continuously observe runtime permission changes so granting permission via the system + // dialog or revoking/re-granting it in Android Settings updates the UI immediately. + LaunchedEffect(appContext) { + while (true) { + hasPermission = appContext.hasAnyLocationPermission() + delay(500L) + } + } + + // TODO(https://github.com/googlemaps/android-maps-utils/issues/1818): Once + // LocationManager.asLocationSource() handles cached lastKnownLocation emission and + // coarse-permission/indoor fallback directly in android-maps-utils, simplify this to + // `remember(manager) { manager.asLocationSource() }` matching PlatformLocationSource.ios.kt. + val locationSource = remember(appContext) { + val manager = appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager + LocationSource { intervalMs, minUpdateDistanceM, priority -> + channelFlow { + if (!appContext.hasAnyLocationPermission()) { + return@channelFlow + } + + // Safely query cached locations across providers; wrap each call in runCatching + // so that coarse-only ("Approximate") permission or a mid-flight revocation + // never throws an unhandled SecurityException. + val lastKnown = runCatching { + manager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + }.getOrNull() + ?: runCatching { + manager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + }.getOrNull() + ?: runCatching { + manager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER) + }.getOrNull() + + if (lastKnown != null) { + send(lastKnown) + } + + val stream = if (appContext.hasFineLocationPermission() && + priority == LocationPriority.HIGH_ACCURACY + ) { + merge( + manager.asLocationSource().locationEvents( + intervalMs = intervalMs, + minUpdateDistanceM = minUpdateDistanceM, + priority = LocationPriority.HIGH_ACCURACY, + ), + manager.coarseLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + ), + ) + } else { + manager.coarseLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + ) + } + + stream.collect { send(it) } + } + } + } + + val openAppSettings: () -> Unit = remember(appContext) { + { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", appContext.packageName, null), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(intent) + } + } + + val requestPermission: () -> Unit = remember(activity, appContext, openAppSettings) { + { + if (activity == null) { + openAppSettings() + } else { + activity.requestPermissions( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + LOCATION_PERMISSION_REQUEST_CODE, + ) + // On Android 11+, if the permission has the USER_FIXED flag set (because the user + // previously denied it twice or revoked it in Settings), Activity.requestPermissions + // silently drops the request ("No requestable permission in the request") without + // launching GrantPermissionsActivity. If our Activity still holds window focus + // 300ms later and permission is still not granted, fall back to opening App Settings. + Handler(Looper.getMainLooper()).postDelayed({ + if (!appContext.hasAnyLocationPermission() && activity.hasWindowFocus()) { + openAppSettings() + } + }, 300L) + } + } + } + + return remember(locationSource, hasPermission, requestPermission, openAppSettings) { + PlatformLocationController( + locationSource = locationSource, + hasPermission = hasPermission, + requestPermission = requestPermission, + openAppSettings = openAppSettings, + ) + } +} diff --git a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt new file mode 100644 index 000000000..80d365b74 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2026 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.multiplatform.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** A demo of maps-compose-multiplatform, shown the same way on Android and iOS. */ +public class MultiplatformDemo internal constructor( + public val title: String, + public val description: String, + internal val content: @Composable () -> Unit, +) + +/** Every demo, in the order the demo list shows them. */ +public val multiplatformDemos: List = listOf( + MultiplatformDemo("Camera", "Move the camera from code and follow the user's gestures") { + CameraDemo() + }, + MultiplatformDemo("Markers", "Markers with info windows, a draggable marker and taps") { + MarkersDemo() + }, + MultiplatformDemo("Shapes", "A polyline, a polygon with a hole and a circle, all tappable") { + ShapesDemo() + }, + MultiplatformDemo("Clustering", "300 markers that cluster and split as you zoom") { + ClusteringDemo() + }, + MultiplatformDemo("Map settings", "Map types, traffic, buildings and gestures") { + MapSettingsDemo() + }, + MultiplatformDemo("Map clicks", "Tap to drop a marker, long press to clear them") { + MapClicksDemo() + }, + MultiplatformDemo("Location Flow", "Live device GPS streamed via KMP LocationSource Flow") { + LocationFlowDemo() + }, +) + +/** + * The demo list and the selected demo. + * + * @param startDemo the index of a demo to open first, or null to start on the list + */ +@Composable +public fun MultiplatformDemoApp(startDemo: Int? = null) { + var selected by rememberSaveable { mutableStateOf(startDemo) } + Column( + Modifier + .fillMaxSize() + .background(Color.White) + .windowInsetsPadding(WindowInsets.safeDrawing), + ) { + val demo = selected?.let { multiplatformDemos.getOrNull(it) } + if (demo == null) { + Header("Multiplatform demos") + DemoList(onDemoClick = { selected = it }) + } else { + Header(demo.title, onBack = { selected = null }) + Box(Modifier.fillMaxSize()) { + demo.content() + } + } + } +} + +@Composable +private fun Header(title: String, onBack: (() -> Unit)? = null) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onBack != null) { + BasicText( + "‹ Demos", + Modifier.clickable(onClick = onBack).padding(end = 16.dp), + style = TextStyle(color = Accent, fontSize = 17.sp), + ) + } + BasicText(title, style = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun DemoList(onDemoClick: (Int) -> Unit) { + LazyColumn(Modifier.fillMaxSize()) { + items(multiplatformDemos.size) { index -> + val demo = multiplatformDemos[index] + Column( + Modifier + .fillMaxWidth() + .clickable { onDemoClick(index) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + BasicText(demo.title, style = TextStyle(fontSize = 17.sp)) + BasicText(demo.description, style = TextStyle(fontSize = 14.sp, color = Color.Gray)) + } + } + } +} + +/** A small button drawn with foundation only, so the demos need no design system. */ +@Composable +internal fun DemoButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + BasicText( + text, + modifier + .background(Accent, RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + style = TextStyle(color = Color.White, fontSize = 14.sp), + ) +} + +/** A line of text over the map, readable on any map type. */ +@Composable +internal fun DemoLabel(text: String, modifier: Modifier = Modifier) { + BasicText( + text, + modifier + .background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + style = TextStyle(fontSize = 14.sp), + ) +} + +@Composable +internal fun DemoControls(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Column(modifier.padding(12.dp)) { + content() + Spacer(Modifier.height(4.dp)) + } +} + +internal val Accent = Color(0xFF1A73E8) diff --git a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt new file mode 100644 index 000000000..1d72d4c13 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt @@ -0,0 +1,371 @@ +/* + * Copyright 2026 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.multiplatform.demo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.compose.multiplatform.Circle +import com.google.maps.android.compose.multiplatform.Clustering +import com.google.maps.android.compose.multiplatform.GoogleMap +import com.google.maps.android.compose.multiplatform.MapProperties +import com.google.maps.android.compose.multiplatform.MapType +import com.google.maps.android.compose.multiplatform.MapUiSettings +import com.google.maps.android.compose.multiplatform.Marker +import com.google.maps.android.compose.multiplatform.Polygon +import com.google.maps.android.compose.multiplatform.Polyline +import com.google.maps.android.compose.multiplatform.cameraPosition +import com.google.maps.android.compose.multiplatform.rememberCameraPositionState +import com.google.maps.android.compose.multiplatform.rememberMarkerState +import com.google.maps.android.location.Location +import com.google.maps.android.location.LocationPriority +import com.google.maps.android.location.accuracyMeters +import com.google.maps.android.location.latLng +import com.google.maps.android.location.latitude +import com.google.maps.android.location.locationEvents +import com.google.maps.android.location.longitude +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import com.google.maps.android.model.target +import com.google.maps.android.model.zoom +import kotlin.math.round +import kotlin.random.Random +import kotlinx.coroutines.launch + +private val SanFrancisco = LatLng(37.7749, -122.4194) +private val London = LatLng(51.5072, -0.1276) +private val Sydney = LatLng(-33.8688, 151.2093) + +@Composable +internal fun CameraDemo() { + val camera = rememberCameraPositionState { position = cameraPosition(SanFrancisco, 11f) } + val scope = rememberCoroutineScope() + Box(Modifier.fillMaxSize()) { + GoogleMap(Modifier.fillMaxSize(), cameraPositionState = camera) + DemoControls(Modifier.align(Alignment.TopStart)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("San Francisco" to SanFrancisco, "London" to London, "Sydney" to Sydney) + .forEach { (name, target) -> + DemoButton(name) { + scope.launch { camera.animate(cameraPosition(target, 11f)) } + } + } + } + Spacer(Modifier.height(8.dp)) + val target = camera.position.target + DemoLabel( + "${target.latitude.format()}, ${target.longitude.format()} at zoom " + + camera.position.zoom.toDouble().format() + + if (camera.isMoving) " (moving)" else "", + ) + } + } +} + +@Composable +internal fun MarkersDemo() { + val camera = rememberCameraPositionState { position = cameraPosition(SanFrancisco, 12f) } + val dragged = rememberMarkerState(LatLng(37.7599, -122.4148)) + var taps by remember { mutableIntStateOf(0) } + Box(Modifier.fillMaxSize()) { + GoogleMap(Modifier.fillMaxSize(), cameraPositionState = camera) { + Marker( + state = rememberMarkerState(LatLng(37.8199, -122.4783)), + title = "Golden Gate Bridge", + snippet = "Tap shows this info window", + onClick = { taps++; false }, + ) + Marker( + state = rememberMarkerState(LatLng(37.8024, -122.4058)), + title = "Coit Tower", + onClick = { taps++; false }, + ) + Marker( + state = dragged, + title = "Drag me", + snippet = "Long press, then drag", + draggable = true, + ) + } + DemoControls(Modifier.align(Alignment.TopStart)) { + DemoLabel( + "Marker taps: $taps\nDragged to ${dragged.position.latitude.format()}, " + + dragged.position.longitude.format(), + ) + } + } +} + +@Composable +internal fun ShapesDemo() { + val camera = rememberCameraPositionState { position = cameraPosition(SanFrancisco, 11f) } + var lastTap by remember { mutableStateOf("Tap a shape") } + Box(Modifier.fillMaxSize()) { + GoogleMap(Modifier.fillMaxSize(), cameraPositionState = camera) { + Polyline( + points = listOf( + LatLng(37.8199, -122.4783), + LatLng(37.8024, -122.4058), + LatLng(37.7955, -122.3937), + ), + color = Accent, + width = 6f, + clickable = true, + onClick = { lastTap = "Polyline" }, + ) + Polygon( + points = listOf( + LatLng(37.7700, -122.5100), + LatLng(37.7700, -122.4500), + LatLng(37.7400, -122.4500), + LatLng(37.7400, -122.5100), + ), + holes = listOf( + listOf( + LatLng(37.7600, -122.4900), + LatLng(37.7600, -122.4700), + LatLng(37.7500, -122.4700), + LatLng(37.7500, -122.4900), + ), + ), + fillColor = Color(0x5534A853), + strokeColor = Color(0xFF34A853), + clickable = true, + onClick = { lastTap = "Polygon" }, + ) + Circle( + center = LatLng(37.7599, -122.4148), + radius = 1_500.0, + fillColor = Color(0x55EA4335), + strokeColor = Color(0xFFEA4335), + clickable = true, + onClick = { lastTap = "Circle" }, + ) + } + DemoControls(Modifier.align(Alignment.TopStart)) { + DemoLabel("Last tap: $lastTap") + } + } +} + +private class Place(override val position: LatLng, override val title: String) : ClusterItem { + override val snippet: String? = null + override val zIndex: Float? = null +} + +@Composable +internal fun ClusteringDemo() { + val camera = rememberCameraPositionState { position = cameraPosition(London, 9f) } + // A fixed seed, so the demo looks the same every time and on both platforms. + val places = remember { + val random = Random(42) + List(300) { index -> + Place( + LatLng( + London.latitude + (random.nextDouble() - 0.5) * 0.6, + London.longitude + (random.nextDouble() - 0.5) * 0.9, + ), + "Place ${index + 1}", + ) + } + } + Box(Modifier.fillMaxSize()) { + GoogleMap(Modifier.fillMaxSize(), cameraPositionState = camera) { + Clustering(places) + } + DemoControls(Modifier.align(Alignment.TopStart)) { + DemoLabel("Zoom ${camera.position.zoom.toDouble().format()}: tap a cluster to zoom in") + } + } +} + +@Composable +internal fun MapSettingsDemo() { + var properties by remember { mutableStateOf(MapProperties()) } + var uiSettings by remember { mutableStateOf(MapUiSettings()) } + Box(Modifier.fillMaxSize()) { + GoogleMap( + Modifier.fillMaxSize(), + cameraPositionState = rememberCameraPositionState { + position = cameraPosition(LatLng(40.7580, -73.9855), 15f) + }, + properties = properties, + uiSettings = uiSettings, + ) + DemoControls(Modifier.align(Alignment.TopStart)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DemoButton("Type: ${properties.mapType.name.lowercase()}") { + val types = MapType.entries + properties = properties.copy( + mapType = types[(properties.mapType.ordinal + 1) % types.size], + ) + } + DemoButton("Traffic: ${properties.isTrafficEnabled.onOff()}") { + properties = properties.copy(isTrafficEnabled = !properties.isTrafficEnabled) + } + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DemoButton("Buildings: ${properties.isBuildingEnabled.onOff()}") { + properties = properties.copy(isBuildingEnabled = !properties.isBuildingEnabled) + } + DemoButton("Gestures: ${uiSettings.scrollGesturesEnabled.onOff()}") { + val enabled = !uiSettings.scrollGesturesEnabled + uiSettings = uiSettings.copy( + scrollGesturesEnabled = enabled, + zoomGesturesEnabled = enabled, + rotationGesturesEnabled = enabled, + tiltGesturesEnabled = enabled, + ) + } + } + } + } +} + +@Composable +internal fun MapClicksDemo() { + val dropped = remember { mutableStateListOf() } + Box(Modifier.fillMaxSize()) { + GoogleMap( + Modifier.fillMaxSize(), + cameraPositionState = rememberCameraPositionState { + position = cameraPosition(Sydney, 12f) + }, + onMapClick = { dropped += it }, + onMapLongClick = { dropped.clear() }, + ) { + dropped.forEachIndexed { index, position -> + Marker(state = rememberMarkerState(position), title = "Marker ${index + 1}") + } + } + DemoControls(Modifier.align(Alignment.TopStart)) { + DemoLabel("${dropped.size} markers: tap to add, long press to clear") + } + } +} + +private fun Double.format(): String = (round(this * 10_000) / 10_000).toString() + +private fun Boolean.onOff(): String = if (this) "on" else "off" + + +@Composable +internal fun LocationFlowDemo() { + val controller = rememberPlatformLocationController() + val camera = rememberCameraPositionState { position = cameraPosition(SanFrancisco, 14f) } + val markerState = rememberMarkerState(SanFrancisco) + var latestLocation by remember { mutableStateOf(null) } + var fixCount by remember { mutableIntStateOf(0) } + var priority by remember { mutableStateOf(LocationPriority.HIGH_ACCURACY) } + val trail = remember { mutableStateListOf() } + + LaunchedEffect(controller.locationSource, priority, controller.hasPermission) { + if (!controller.hasPermission) { + latestLocation = null + return@LaunchedEffect + } + controller.locationSource.locationEvents( + intervalMs = 1_000L, + minUpdateDistanceM = 0f, + priority = priority, + ).collect { location -> + latestLocation = location + fixCount++ + val target = location.latLng + if (trail.lastOrNull() != target) { + trail += target + } + markerState.position = target + camera.animate(cameraPosition(target, 15f)) + } + } + + Box(Modifier.fillMaxSize()) { + GoogleMap(Modifier.fillMaxSize(), cameraPositionState = camera) { + val loc = latestLocation + if (loc != null && controller.hasPermission) { + Marker( + state = markerState, + title = "Live Location (KMP Flow)", + snippet = "Fix #$fixCount (${loc.latitude.format()}, ${loc.longitude.format()})", + ) + val accuracyRadius = loc.accuracyMeters.toDouble().coerceAtLeast(25.0) + Circle( + center = loc.latLng, + radius = accuracyRadius, + fillColor = Color(0x441A73E8), + strokeColor = Accent, + strokeWidth = 3f, + ) + if (trail.size >= 2) { + Polyline( + points = trail.toList(), + color = Accent, + width = 8f, + ) + } + } + } + DemoControls(Modifier.align(Alignment.TopStart)) { + if (!controller.hasPermission) { + DemoLabel( + "Location permission required.\n" + + "Grant permission to stream live LocationSource updates.", + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DemoButton("Grant Permission") { controller.requestPermission() } + DemoButton("App Settings") { controller.openAppSettings() } + } + } else { + val loc = latestLocation + if (loc == null) { + DemoLabel("Waiting for LocationSource.locationEvents()...") + } else { + DemoLabel( + "Flow Fix #$fixCount: ${loc.latitude.format()}, ${loc.longitude.format()}\n" + + "Accuracy: ${loc.accuracyMeters.toDouble().format()}m · Mode: ${priority.name}", + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DemoButton("High Accuracy") { priority = LocationPriority.HIGH_ACCURACY } + DemoButton("Balanced") { priority = LocationPriority.BALANCED_POWER_ACCURACY } + } + } + } + } +} diff --git a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.kt b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.kt new file mode 100644 index 000000000..7f24694f5 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 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.multiplatform.demo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.google.maps.android.location.LocationSource + +/** + * Holds a multiplatform [LocationSource] alongside observable runtime permission state and + * platform callbacks to request permission or open system application settings. + * + * Exposing permission state alongside [locationSource] allows shared `commonMain` UI to react + * gracefully when the user denies or revokes location permission at runtime, rather than crashing + * or hanging indefinitely. + */ +@Stable +internal class PlatformLocationController( + val locationSource: LocationSource, + val hasPermission: Boolean, + val requestPermission: () -> Unit, + val openAppSettings: () -> Unit, +) + +/** + * Remembers a platform-backed [PlatformLocationController] (`LocationManager` on Android, + * `CLLocationManager` on iOS) that tracks runtime location permissions and streams device GPS + * fixes into shared `commonMain` UI via [LocationSource.locationEvents]. + */ +@Composable +internal expect fun rememberPlatformLocationController(): PlatformLocationController diff --git a/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/MainViewController.kt b/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/MainViewController.kt new file mode 100644 index 000000000..3b0b11e91 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/MainViewController.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 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.multiplatform.demo + +import androidx.compose.ui.window.ComposeUIViewController +import platform.Foundation.NSUserDefaults +import platform.UIKit.UIViewController + +/** + * The demo app as a view controller, for the iOS app's window. + * + * Launching the app with `-demo ` opens that demo directly, for example + * `xcrun simctl launch booted com.google.maps.android.compose.iosApp -demo 3`. + */ +public fun MainViewController(): UIViewController = ComposeUIViewController( + configure = { + // The demo app's Info.plist is generated by create_project.rb and is not checked by + // Compose's sanity checks. + enforceStrictPlistSanityCheck = false + }, +) { + MultiplatformDemoApp( + startDemo = NSUserDefaults.standardUserDefaults.stringForKey("demo")?.toIntOrNull(), + ) +} diff --git a/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.ios.kt b/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.ios.kt new file mode 100644 index 000000000..86d1aa1be --- /dev/null +++ b/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.ios.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2026 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.multiplatform.demo + +import androidx.compose.runtime.Composable +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 com.google.maps.android.location.asLocationSource +import kotlinx.coroutines.delay +import platform.CoreLocation.CLAuthorizationStatus +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.kCLAuthorizationStatusAuthorizedAlways +import platform.CoreLocation.kCLAuthorizationStatusAuthorizedWhenInUse +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString + +private fun CLAuthorizationStatus.isAuthorized(): Boolean = + this == kCLAuthorizationStatusAuthorizedWhenInUse || + this == kCLAuthorizationStatusAuthorizedAlways + +@Composable +internal actual fun rememberPlatformLocationController(): PlatformLocationController { + val manager = remember { CLLocationManager() } + var hasPermission by remember { mutableStateOf(manager.authorizationStatus.isAuthorized()) } + + LaunchedEffect(manager) { + while (true) { + hasPermission = manager.authorizationStatus.isAuthorized() + delay(500L) + } + } + + val locationSource = remember(manager) { manager.asLocationSource() } + + val requestPermission: () -> Unit = remember(manager) { + { manager.requestWhenInUseAuthorization() } + } + + val openAppSettings: () -> Unit = remember { + { + val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString) + if (url != null) { + UIApplication.sharedApplication.openURL( + url, + options = emptyMap(), + completionHandler = null, + ) + } + } + } + + return remember(locationSource, hasPermission, requestPermission, openAppSettings) { + PlatformLocationController( + locationSource = locationSource, + hasPermission = hasPermission, + requestPermission = requestPermission, + openAppSettings = openAppSettings, + ) + } +} diff --git a/maps-compose-multiplatform/api/maps-compose-multiplatform.api b/maps-compose-multiplatform/api/maps-compose-multiplatform.api new file mode 100644 index 000000000..fde0e189e --- /dev/null +++ b/maps-compose-multiplatform/api/maps-compose-multiplatform.api @@ -0,0 +1,102 @@ +public final class com/google/maps/android/compose/multiplatform/CameraPositionState { + public static final field $stable I + public static final field Companion Lcom/google/maps/android/compose/multiplatform/CameraPositionState$Companion; + public fun ()V + public fun (Lcom/google/android/gms/maps/model/CameraPosition;)V + public synthetic fun (Lcom/google/android/gms/maps/model/CameraPosition;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun animate (Lcom/google/android/gms/maps/model/CameraPosition;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun animate$default (Lcom/google/maps/android/compose/multiplatform/CameraPositionState;Lcom/google/android/gms/maps/model/CameraPosition;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun getPosition ()Lcom/google/android/gms/maps/model/CameraPosition; + public final fun isMoving ()Z + public final fun setPosition (Lcom/google/android/gms/maps/model/CameraPosition;)V +} + +public final class com/google/maps/android/compose/multiplatform/CameraPositionState$Companion { + public final fun getSaver ()Landroidx/compose/runtime/saveable/Saver; +} + +public final class com/google/maps/android/compose/multiplatform/CameraPositionStateKt { + public static final fun cameraPosition (Lcom/google/android/gms/maps/model/LatLng;F)Lcom/google/android/gms/maps/model/CameraPosition; + public static final fun rememberCameraPositionState (Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)Lcom/google/maps/android/compose/multiplatform/CameraPositionState; +} + +public final class com/google/maps/android/compose/multiplatform/ClusteringKt { + public static final fun Clustering-FHprtrg (Ljava/util/Collection;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;JILandroidx/compose/runtime/Composer;II)V + public static final fun clusterItems (Ljava/util/Collection;FI)Ljava/util/List; + public static synthetic fun clusterItems$default (Ljava/util/Collection;FIILjava/lang/Object;)Ljava/util/List; +} + +public final class com/google/maps/android/compose/multiplatform/ComposableSingletons$GoogleMapKt { + public static final field INSTANCE Lcom/google/maps/android/compose/multiplatform/ComposableSingletons$GoogleMapKt; + public fun ()V + public final fun getLambda$1285163986$com_google_maps_android_maps_compose_multiplatform ()Lkotlin/jvm/functions/Function2; +} + +public final class com/google/maps/android/compose/multiplatform/GoogleMapKt { + public static final fun GoogleMap (Landroidx/compose/ui/Modifier;Lcom/google/maps/android/compose/multiplatform/CameraPositionState;Lcom/google/maps/android/compose/multiplatform/MapProperties;Lcom/google/maps/android/compose/multiplatform/MapUiSettings;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/google/maps/android/compose/multiplatform/MapContentKt { + public static final fun Circle--nWoaYo (Lcom/google/android/gms/maps/model/LatLng;DJJFZZFLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun Marker-v-yJ_3Y (Lcom/google/maps/android/compose/multiplatform/MarkerState;Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/graphics/Color;FZZFZFLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final fun Polygon-QbvVL9M (Ljava/util/List;Ljava/util/List;JJFZZZFLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun Polyline-Y2L_72g (Ljava/util/List;JFZZZFLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun rememberMarkerState (Lcom/google/android/gms/maps/model/LatLng;Landroidx/compose/runtime/Composer;I)Lcom/google/maps/android/compose/multiplatform/MarkerState; +} + +public final class com/google/maps/android/compose/multiplatform/MapProperties { + public static final field $stable I + public fun ()V + public fun (Lcom/google/maps/android/compose/multiplatform/MapType;ZZZZLjava/lang/String;FF)V + public synthetic fun (Lcom/google/maps/android/compose/multiplatform/MapType;ZZZZLjava/lang/String;FFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun copy (Lcom/google/maps/android/compose/multiplatform/MapType;ZZZZLjava/lang/String;FF)Lcom/google/maps/android/compose/multiplatform/MapProperties; + public static synthetic fun copy$default (Lcom/google/maps/android/compose/multiplatform/MapProperties;Lcom/google/maps/android/compose/multiplatform/MapType;ZZZZLjava/lang/String;FFILjava/lang/Object;)Lcom/google/maps/android/compose/multiplatform/MapProperties; + public fun equals (Ljava/lang/Object;)Z + public final fun getMapStyleJson ()Ljava/lang/String; + public final fun getMapType ()Lcom/google/maps/android/compose/multiplatform/MapType; + public final fun getMaxZoomPreference ()F + public final fun getMinZoomPreference ()F + public fun hashCode ()I + public final fun isBuildingEnabled ()Z + public final fun isIndoorEnabled ()Z + public final fun isMyLocationEnabled ()Z + public final fun isTrafficEnabled ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/google/maps/android/compose/multiplatform/MapType : java/lang/Enum { + public static final field HYBRID Lcom/google/maps/android/compose/multiplatform/MapType; + public static final field NONE Lcom/google/maps/android/compose/multiplatform/MapType; + public static final field NORMAL Lcom/google/maps/android/compose/multiplatform/MapType; + public static final field SATELLITE Lcom/google/maps/android/compose/multiplatform/MapType; + public static final field TERRAIN Lcom/google/maps/android/compose/multiplatform/MapType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/google/maps/android/compose/multiplatform/MapType; + public static fun values ()[Lcom/google/maps/android/compose/multiplatform/MapType; +} + +public final class com/google/maps/android/compose/multiplatform/MapUiSettings { + public static final field $stable I + public fun ()V + public fun (ZZZZZZ)V + public synthetic fun (ZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun copy (ZZZZZZ)Lcom/google/maps/android/compose/multiplatform/MapUiSettings; + public static synthetic fun copy$default (Lcom/google/maps/android/compose/multiplatform/MapUiSettings;ZZZZZZILjava/lang/Object;)Lcom/google/maps/android/compose/multiplatform/MapUiSettings; + public fun equals (Ljava/lang/Object;)Z + public final fun getCompassEnabled ()Z + public final fun getMyLocationButtonEnabled ()Z + public final fun getRotationGesturesEnabled ()Z + public final fun getScrollGesturesEnabled ()Z + public final fun getTiltGesturesEnabled ()Z + public final fun getZoomGesturesEnabled ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/google/maps/android/compose/multiplatform/MarkerState { + public static final field $stable I + public fun (Lcom/google/android/gms/maps/model/LatLng;)V + public final fun getPosition ()Lcom/google/android/gms/maps/model/LatLng; + public final fun setPosition (Lcom/google/android/gms/maps/model/LatLng;)V +} + diff --git a/maps-compose-multiplatform/api/maps-compose-multiplatform.klib.api b/maps-compose-multiplatform/api/maps-compose-multiplatform.klib.api new file mode 100644 index 000000000..2ee454b00 --- /dev/null +++ b/maps-compose-multiplatform/api/maps-compose-multiplatform.klib.api @@ -0,0 +1,114 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final enum class com.google.maps.android.compose.multiplatform/MapType : kotlin/Enum { // com.google.maps.android.compose.multiplatform/MapType|null[0] + enum entry HYBRID // com.google.maps.android.compose.multiplatform/MapType.HYBRID|null[0] + enum entry NONE // com.google.maps.android.compose.multiplatform/MapType.NONE|null[0] + enum entry NORMAL // com.google.maps.android.compose.multiplatform/MapType.NORMAL|null[0] + enum entry SATELLITE // com.google.maps.android.compose.multiplatform/MapType.SATELLITE|null[0] + enum entry TERRAIN // com.google.maps.android.compose.multiplatform/MapType.TERRAIN|null[0] + + final val entries // com.google.maps.android.compose.multiplatform/MapType.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.google.maps.android.compose.multiplatform/MapType.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.google.maps.android.compose.multiplatform/MapType // com.google.maps.android.compose.multiplatform/MapType.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.google.maps.android.compose.multiplatform/MapType.values|values#static(){}[0] +} + +final class com.google.maps.android.compose.multiplatform/CameraPositionState { // com.google.maps.android.compose.multiplatform/CameraPositionState|null[0] + constructor (com.google.maps.android.model/CameraPosition = ...) // com.google.maps.android.compose.multiplatform/CameraPositionState.|(com.google.maps.android.model.CameraPosition){}[0] + + final var isMoving // com.google.maps.android.compose.multiplatform/CameraPositionState.isMoving|{}isMoving[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/CameraPositionState.isMoving.|(){}[0] + final var position // com.google.maps.android.compose.multiplatform/CameraPositionState.position|{}position[0] + final fun (): com.google.maps.android.model/CameraPosition // com.google.maps.android.compose.multiplatform/CameraPositionState.position.|(){}[0] + final fun (com.google.maps.android.model/CameraPosition) // com.google.maps.android.compose.multiplatform/CameraPositionState.position.|(com.google.maps.android.model.CameraPosition){}[0] + + final suspend fun animate(com.google.maps.android.model/CameraPosition, kotlin/Int = ...) // com.google.maps.android.compose.multiplatform/CameraPositionState.animate|animate(com.google.maps.android.model.CameraPosition;kotlin.Int){}[0] + + final object Companion { // com.google.maps.android.compose.multiplatform/CameraPositionState.Companion|null[0] + final val Saver // com.google.maps.android.compose.multiplatform/CameraPositionState.Companion.Saver|{}Saver[0] + final fun (): androidx.compose.runtime.saveable/Saver // com.google.maps.android.compose.multiplatform/CameraPositionState.Companion.Saver.|(){}[0] + } +} + +final class com.google.maps.android.compose.multiplatform/MapProperties { // com.google.maps.android.compose.multiplatform/MapProperties|null[0] + constructor (com.google.maps.android.compose.multiplatform/MapType = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String? = ..., kotlin/Float = ..., kotlin/Float = ...) // com.google.maps.android.compose.multiplatform/MapProperties.|(com.google.maps.android.compose.multiplatform.MapType;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String?;kotlin.Float;kotlin.Float){}[0] + + final val isBuildingEnabled // com.google.maps.android.compose.multiplatform/MapProperties.isBuildingEnabled|{}isBuildingEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapProperties.isBuildingEnabled.|(){}[0] + final val isIndoorEnabled // com.google.maps.android.compose.multiplatform/MapProperties.isIndoorEnabled|{}isIndoorEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapProperties.isIndoorEnabled.|(){}[0] + final val isMyLocationEnabled // com.google.maps.android.compose.multiplatform/MapProperties.isMyLocationEnabled|{}isMyLocationEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapProperties.isMyLocationEnabled.|(){}[0] + final val isTrafficEnabled // com.google.maps.android.compose.multiplatform/MapProperties.isTrafficEnabled|{}isTrafficEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapProperties.isTrafficEnabled.|(){}[0] + final val mapStyleJson // com.google.maps.android.compose.multiplatform/MapProperties.mapStyleJson|{}mapStyleJson[0] + final fun (): kotlin/String? // com.google.maps.android.compose.multiplatform/MapProperties.mapStyleJson.|(){}[0] + final val mapType // com.google.maps.android.compose.multiplatform/MapProperties.mapType|{}mapType[0] + final fun (): com.google.maps.android.compose.multiplatform/MapType // com.google.maps.android.compose.multiplatform/MapProperties.mapType.|(){}[0] + final val maxZoomPreference // com.google.maps.android.compose.multiplatform/MapProperties.maxZoomPreference|{}maxZoomPreference[0] + final fun (): kotlin/Float // com.google.maps.android.compose.multiplatform/MapProperties.maxZoomPreference.|(){}[0] + final val minZoomPreference // com.google.maps.android.compose.multiplatform/MapProperties.minZoomPreference|{}minZoomPreference[0] + final fun (): kotlin/Float // com.google.maps.android.compose.multiplatform/MapProperties.minZoomPreference.|(){}[0] + + final fun copy(com.google.maps.android.compose.multiplatform/MapType = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String? = ..., kotlin/Float = ..., kotlin/Float = ...): com.google.maps.android.compose.multiplatform/MapProperties // com.google.maps.android.compose.multiplatform/MapProperties.copy|copy(com.google.maps.android.compose.multiplatform.MapType;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String?;kotlin.Float;kotlin.Float){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapProperties.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.google.maps.android.compose.multiplatform/MapProperties.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.google.maps.android.compose.multiplatform/MapProperties.toString|toString(){}[0] +} + +final class com.google.maps.android.compose.multiplatform/MapUiSettings { // com.google.maps.android.compose.multiplatform/MapUiSettings|null[0] + constructor (kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...) // com.google.maps.android.compose.multiplatform/MapUiSettings.|(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0] + + final val compassEnabled // com.google.maps.android.compose.multiplatform/MapUiSettings.compassEnabled|{}compassEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapUiSettings.compassEnabled.|(){}[0] + final val myLocationButtonEnabled // com.google.maps.android.compose.multiplatform/MapUiSettings.myLocationButtonEnabled|{}myLocationButtonEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapUiSettings.myLocationButtonEnabled.|(){}[0] + final val rotationGesturesEnabled // com.google.maps.android.compose.multiplatform/MapUiSettings.rotationGesturesEnabled|{}rotationGesturesEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapUiSettings.rotationGesturesEnabled.|(){}[0] + final val scrollGesturesEnabled // com.google.maps.android.compose.multiplatform/MapUiSettings.scrollGesturesEnabled|{}scrollGesturesEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapUiSettings.scrollGesturesEnabled.|(){}[0] + final val tiltGesturesEnabled // com.google.maps.android.compose.multiplatform/MapUiSettings.tiltGesturesEnabled|{}tiltGesturesEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapUiSettings.tiltGesturesEnabled.|(){}[0] + final val zoomGesturesEnabled // com.google.maps.android.compose.multiplatform/MapUiSettings.zoomGesturesEnabled|{}zoomGesturesEnabled[0] + final fun (): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapUiSettings.zoomGesturesEnabled.|(){}[0] + + final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ...): com.google.maps.android.compose.multiplatform/MapUiSettings // com.google.maps.android.compose.multiplatform/MapUiSettings.copy|copy(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapUiSettings.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.google.maps.android.compose.multiplatform/MapUiSettings.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.google.maps.android.compose.multiplatform/MapUiSettings.toString|toString(){}[0] +} + +final class com.google.maps.android.compose.multiplatform/MarkerState { // com.google.maps.android.compose.multiplatform/MarkerState|null[0] + constructor (com.google.maps.android.model/LatLng) // com.google.maps.android.compose.multiplatform/MarkerState.|(com.google.maps.android.model.LatLng){}[0] + + final var position // com.google.maps.android.compose.multiplatform/MarkerState.position|{}position[0] + final fun (): com.google.maps.android.model/LatLng // com.google.maps.android.compose.multiplatform/MarkerState.position.|(){}[0] + final fun (com.google.maps.android.model/LatLng) // com.google.maps.android.compose.multiplatform/MarkerState.position.|(com.google.maps.android.model.LatLng){}[0] +} + +final val com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_CameraPositionState$stableprop // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_CameraPositionState$stableprop|#static{}com_google_maps_android_compose_multiplatform_CameraPositionState$stableprop[0] +final val com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapProperties$stableprop // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapProperties$stableprop|#static{}com_google_maps_android_compose_multiplatform_MapProperties$stableprop[0] +final val com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapUiSettings$stableprop // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapUiSettings$stableprop|#static{}com_google_maps_android_compose_multiplatform_MapUiSettings$stableprop[0] +final val com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MarkerState$stableprop // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MarkerState$stableprop|#static{}com_google_maps_android_compose_multiplatform_MarkerState$stableprop[0] + +final fun <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.compose.multiplatform/Clustering(kotlin.collections/Collection<#A>, kotlin/Function1, kotlin/Boolean>?, kotlin/Function1<#A, kotlin/Boolean>?, kotlin/Function1, kotlin/String>?, androidx.compose.ui.graphics/Color, kotlin/Int, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/Clustering|Clustering(kotlin.collections.Collection<0:0>;kotlin.Function1,kotlin.Boolean>?;kotlin.Function1<0:0,kotlin.Boolean>?;kotlin.Function1,kotlin.String>?;androidx.compose.ui.graphics.Color;kotlin.Int;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +final fun <#A: com.google.maps.android.clustering/ClusterItem> com.google.maps.android.compose.multiplatform/clusterItems(kotlin.collections/Collection<#A>, kotlin/Float, kotlin/Int = ...): kotlin.collections/List> // com.google.maps.android.compose.multiplatform/clusterItems|clusterItems(kotlin.collections.Collection<0:0>;kotlin.Float;kotlin.Int){0§}[0] +final fun com.google.maps.android.compose.multiplatform/Circle(com.google.maps.android.model/LatLng, kotlin/Double, androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color, kotlin/Float, kotlin/Boolean, kotlin/Boolean, kotlin/Float, kotlin/Function0?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/Circle|Circle(com.google.maps.android.model.LatLng;kotlin.Double;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean;kotlin.Boolean;kotlin.Float;kotlin.Function0?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/GoogleMap(androidx.compose.ui/Modifier?, com.google.maps.android.compose.multiplatform/CameraPositionState?, com.google.maps.android.compose.multiplatform/MapProperties?, com.google.maps.android.compose.multiplatform/MapUiSettings?, kotlin/Function1?, kotlin/Function1?, kotlin/Function0?, kotlin/Function2?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/GoogleMap|GoogleMap(androidx.compose.ui.Modifier?;com.google.maps.android.compose.multiplatform.CameraPositionState?;com.google.maps.android.compose.multiplatform.MapProperties?;com.google.maps.android.compose.multiplatform.MapUiSettings?;kotlin.Function1?;kotlin.Function1?;kotlin.Function0?;kotlin.Function2?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/Marker(com.google.maps.android.compose.multiplatform/MarkerState, kotlin/String?, kotlin/String?, androidx.compose.ui.graphics/Color?, kotlin/Float, kotlin/Boolean, kotlin/Boolean, kotlin/Float, kotlin/Boolean, kotlin/Float, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/Marker|Marker(com.google.maps.android.compose.multiplatform.MarkerState;kotlin.String?;kotlin.String?;androidx.compose.ui.graphics.Color?;kotlin.Float;kotlin.Boolean;kotlin.Boolean;kotlin.Float;kotlin.Boolean;kotlin.Float;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/Polygon(kotlin.collections/List, kotlin.collections/List>?, androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color, kotlin/Float, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Float, kotlin/Function0?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/Polygon|Polygon(kotlin.collections.List;kotlin.collections.List>?;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Float;kotlin.Function0?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/Polyline(kotlin.collections/List, androidx.compose.ui.graphics/Color, kotlin/Float, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Float, kotlin/Function0?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/Polyline|Polyline(kotlin.collections.List;androidx.compose.ui.graphics.Color;kotlin.Float;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Float;kotlin.Function0?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/cameraPosition(com.google.maps.android.model/LatLng, kotlin/Float): com.google.maps.android.model/CameraPosition // com.google.maps.android.compose.multiplatform/cameraPosition|cameraPosition(com.google.maps.android.model.LatLng;kotlin.Float){}[0] +final fun com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_CameraPositionState$stableprop_getter(): kotlin/Int // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_CameraPositionState$stableprop_getter|com_google_maps_android_compose_multiplatform_CameraPositionState$stableprop_getter(){}[0] +final fun com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapProperties$stableprop_getter(): kotlin/Int // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapProperties$stableprop_getter|com_google_maps_android_compose_multiplatform_MapProperties$stableprop_getter(){}[0] +final fun com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapUiSettings$stableprop_getter(): kotlin/Int // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapUiSettings$stableprop_getter|com_google_maps_android_compose_multiplatform_MapUiSettings$stableprop_getter(){}[0] +final fun com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MarkerState$stableprop_getter(): kotlin/Int // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MarkerState$stableprop_getter|com_google_maps_android_compose_multiplatform_MarkerState$stableprop_getter(){}[0] +final fun com.google.maps.android.compose.multiplatform/rememberMarkerState(com.google.maps.android.model/LatLng, androidx.compose.runtime/Composer?, kotlin/Int): com.google.maps.android.compose.multiplatform/MarkerState // com.google.maps.android.compose.multiplatform/rememberMarkerState|rememberMarkerState(com.google.maps.android.model.LatLng;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final inline fun com.google.maps.android.compose.multiplatform/rememberCameraPositionState(crossinline kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): com.google.maps.android.compose.multiplatform/CameraPositionState // com.google.maps.android.compose.multiplatform/rememberCameraPositionState|rememberCameraPositionState(kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts new file mode 100644 index 000000000..98063e37a --- /dev/null +++ b/maps-compose-multiplatform/build.gradle.kts @@ -0,0 +1,115 @@ +/* + * Copyright 2026 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. + */ + +plugins { + id("android.maps.compose.KmpPublishingConventionPlugin") + kotlin("native.cocoapods") + alias(libs.plugins.compose.compiler) +} + +kotlin { + android { + namespace = "com.google.maps.android.compose.multiplatform" + compileSdk = libs.versions.androidCompileSdk.get().toInt() + minSdk = libs.versions.androidMinSdk.get().toInt() + + // Runs commonTest on the JVM as well as on the iOS simulator. + withHostTestBuilder { + }.configure { + isReturnDefaultValues = true + } + } + + cocoapods { + summary = "Multiplatform Google Maps wrapper" + homepage = "https://github.com/googlemaps/android-maps-compose" + version = project.version.toString() + ios.deploymentTarget = "16.0" + pod("GoogleMaps") { + version = "10.14.0.0" + } + framework { + baseName = "maps_compose_multiplatform" + isStatic = true + linkerOpts("-lc++") + linkerOpts("-framework", "Accelerate") + linkerOpts("-framework", "CoreData") + linkerOpts("-framework", "CoreGraphics") + linkerOpts("-framework", "CoreImage") + linkerOpts("-framework", "CoreLocation") + linkerOpts("-framework", "CoreText") + linkerOpts("-framework", "GLKit") + linkerOpts("-framework", "ImageIO") + linkerOpts("-framework", "Metal") + linkerOpts("-framework", "OpenGLES") + linkerOpts("-framework", "QuartzCore") + linkerOpts("-framework", "Security") + linkerOpts("-framework", "SystemConfiguration") + linkerOpts("-framework", "UIKit") + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.compose.multiplatform.runtime) + implementation(libs.compose.multiplatform.foundation) + implementation(libs.compose.multiplatform.ui) + // Multiplatform clustering from the android-maps-utils KMP branch, published as + // a snapshot (see settings.gradle.kts). It shares its artifactId with the AAR that + // maps-compose-utils pulls from Maven Central, so the two resolve to one module. + api(libs.maps.utils.kmp.maps.model) + api(libs.maps.utils.kmp.core) + api(libs.maps.utils.kmp.clustering) + } + } + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + androidMain { + dependencies { + // Link the existing maps-compose module locally + api(project(":maps-compose")) + } + } + iosMain { + dependencies { + // Uses native MapKit via platform libraries + } + } + } +} + +mavenPublishing { + coordinates(artifactId = "maps-compose-multiplatform") +} + +// A release must not depend on a snapshot of android-maps-utils: consumers could not resolve it, +// and it would change under them. Move mapsutilsKmp to a release before publishing one. +val mapsUtilsKmpVersion = libs.versions.mapsutilsKmp.get() +tasks.withType().configureEach { + val releasing = !project.version.toString().endsWith("-SNAPSHOT") + doFirst { + check(!(releasing && mapsUtilsKmpVersion.endsWith("-SNAPSHOT"))) { + "Cannot publish ${project.version} of maps-compose-multiplatform against " + + "android-maps-utils $mapsUtilsKmpVersion. Update mapsutilsKmp in " + + "gradle/libs.versions.toml to a released version first." + } + } +} diff --git a/maps-compose-multiplatform/maps_compose_multiplatform.podspec b/maps-compose-multiplatform/maps_compose_multiplatform.podspec new file mode 100644 index 000000000..1d28cb5d9 --- /dev/null +++ b/maps-compose-multiplatform/maps_compose_multiplatform.podspec @@ -0,0 +1,46 @@ +Pod::Spec.new do |spec| + spec.name = 'maps_compose_multiplatform' + spec.version = '9.0.0' + spec.homepage = 'https://github.com/googlemaps/android-maps-compose' + spec.source = { :http=> ''} + spec.authors = '' + spec.license = '' + spec.summary = 'Multiplatform Google Maps wrapper' + spec.vendored_frameworks = 'build/cocoapods/framework/maps_compose_multiplatform.framework' + spec.libraries = 'c++' + spec.ios.deployment_target = '16.0' + spec.dependency 'GoogleMaps', '10.14.0.0' + if !Dir.exist?('build/cocoapods/framework/maps_compose_multiplatform.framework') || Dir.empty?('build/cocoapods/framework/maps_compose_multiplatform.framework') + raise " + Kotlin framework 'maps_compose_multiplatform' doesn't exist yet, so a proper Xcode project can't be generated. + 'pod install' should be executed after running ':generateDummyFramework' Gradle task: + ./gradlew :maps-compose-multiplatform:generateDummyFramework + Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)" + end + spec.xcconfig = { + 'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO', + } + spec.pod_target_xcconfig = { + 'KOTLIN_PROJECT_PATH' => ':maps-compose-multiplatform', + 'PRODUCT_MODULE_NAME' => 'maps_compose_multiplatform', + } + spec.script_phases = [ + { + :name => 'Build maps_compose_multiplatform', + :execution_position => :before_compile, + :shell_path => '/bin/sh', + :script => <<-SCRIPT + if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then + echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" + exit 0 + fi + set -ev + REPO_ROOT="$PODS_TARGET_SRCROOT" + "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \ + -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \ + -Pkotlin.native.cocoapods.archs="$ARCHS" \ + -Pkotlin.native.cocoapods.configuration="$CONFIGURATION" + SCRIPT + } + ] +end diff --git a/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.android.kt b/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.android.kt new file mode 100644 index 000000000..9fc01370f --- /dev/null +++ b/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.android.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.MapStyleOptions +import com.google.maps.android.compose.Circle as MapsComposeCircle +import com.google.maps.android.compose.GoogleMap as MapsComposeGoogleMap +import com.google.maps.android.compose.MapProperties as MapsComposeMapProperties +import com.google.maps.android.compose.MapType as MapsComposeMapType +import com.google.maps.android.compose.MapUiSettings as MapsComposeMapUiSettings +import com.google.maps.android.compose.Marker as MapsComposeMarker +import com.google.maps.android.compose.MarkerState as MapsComposeMarkerState +import com.google.maps.android.compose.Polygon as MapsComposePolygon +import com.google.maps.android.compose.Polyline as MapsComposePolyline +import com.google.maps.android.compose.rememberCameraPositionState as rememberMapsComposeCameraPositionState +import com.google.maps.android.model.CameraPosition +import com.google.maps.android.model.LatLng + +@Composable +internal actual fun PlatformGoogleMap( + modifier: Modifier, + cameraPositionState: CameraPositionState, + properties: MapProperties, + uiSettings: MapUiSettings, + onMapClick: (LatLng) -> Unit, + onMapLongClick: (LatLng) -> Unit, + onMapLoaded: () -> Unit, + registry: MapNodeRegistry, +) { + // The shared CameraPosition is the Play services class on Android, so it passes through. + val mapCameraState = rememberMapsComposeCameraPositionState { + position = cameraPositionState.position + } + DisposableEffect(cameraPositionState, mapCameraState) { + cameraPositionState.controller = object : CameraController { + override fun move(position: CameraPosition) { + mapCameraState.move(CameraUpdateFactory.newCameraPosition(position)) + } + + override suspend fun animate(position: CameraPosition, durationMs: Int) { + mapCameraState.animate(CameraUpdateFactory.newCameraPosition(position), durationMs) + } + } + onDispose { cameraPositionState.controller = null } + } + LaunchedEffect(cameraPositionState, mapCameraState) { + snapshotFlow { mapCameraState.position to mapCameraState.isMoving } + .collect { (position, isMoving) -> cameraPositionState.onCameraChanged(position, isMoving) } + } + + val mapProperties = remember(properties) { properties.toMapsCompose() } + val mapUiSettings = remember(uiSettings) { uiSettings.toMapsCompose() } + + MapsComposeGoogleMap( + modifier = modifier, + cameraPositionState = mapCameraState, + properties = mapProperties, + uiSettings = mapUiSettings, + onMapClick = onMapClick, + onMapLongClick = onMapLongClick, + onMapLoaded = onMapLoaded, + ) { + registry.nodes.forEach { node -> + key(node) { + when (node) { + is MarkerNode -> MarkerNodeContent(node) + is PolylineNode -> MapsComposePolyline( + points = node.points, + color = node.color, + width = node.width.dpToPx(), + geodesic = node.geodesic, + clickable = node.clickable, + visible = node.visible, + zIndex = node.zIndex, + onClick = { node.onClick() }, + ) + is PolygonNode -> MapsComposePolygon( + points = node.points, + holes = node.holes, + fillColor = node.fillColor, + strokeColor = node.strokeColor, + strokeWidth = node.strokeWidth.dpToPx(), + geodesic = node.geodesic, + clickable = node.clickable, + visible = node.visible, + zIndex = node.zIndex, + onClick = { node.onClick() }, + ) + is CircleNode -> MapsComposeCircle( + center = node.center, + radius = node.radius, + fillColor = node.fillColor, + strokeColor = node.strokeColor, + strokeWidth = node.strokeWidth.dpToPx(), + clickable = node.clickable, + visible = node.visible, + zIndex = node.zIndex, + onClick = { node.onClick() }, + ) + } + } + } + } +} + +/** + * maps-compose keeps its own MarkerState, so mirror the shared one into it, and mirror drags + * back. Both directions skip equal positions, so they do not echo each other. + */ +@Composable +private fun MarkerNodeContent(node: MarkerNode) { + val sharedState = node.state + val mapState = remember(sharedState) { MapsComposeMarkerState(sharedState.position) } + LaunchedEffect(sharedState, mapState) { + snapshotFlow { sharedState.position }.collect { + if (mapState.position != it) mapState.position = it + } + } + LaunchedEffect(sharedState, mapState) { + snapshotFlow { mapState.position }.collect { + if (sharedState.position != it) sharedState.position = it + } + } + MapsComposeMarker( + state = mapState, + title = node.title, + snippet = node.snippet, + icon = remember(node.color) { node.color?.let { BitmapDescriptorFactory.defaultMarker(it.hue()) } }, + alpha = node.alpha, + draggable = node.draggable, + flat = node.flat, + rotation = node.rotation, + visible = node.visible, + zIndex = node.zIndex, + onClick = { node.onClick(sharedState) }, + ) +} + +private fun MapProperties.toMapsCompose() = MapsComposeMapProperties( + isBuildingEnabled = isBuildingEnabled, + isIndoorEnabled = isIndoorEnabled, + isMyLocationEnabled = isMyLocationEnabled, + isTrafficEnabled = isTrafficEnabled, + mapStyleOptions = mapStyleJson?.let { MapStyleOptions(it) }, + mapType = when (mapType) { + MapType.NONE -> MapsComposeMapType.NONE + MapType.NORMAL -> MapsComposeMapType.NORMAL + MapType.SATELLITE -> MapsComposeMapType.SATELLITE + MapType.TERRAIN -> MapsComposeMapType.TERRAIN + MapType.HYBRID -> MapsComposeMapType.HYBRID + }, + maxZoomPreference = maxZoomPreference, + minZoomPreference = minZoomPreference, +) + +private fun MapUiSettings.toMapsCompose() = MapsComposeMapUiSettings( + compassEnabled = compassEnabled, + myLocationButtonEnabled = myLocationButtonEnabled, + rotationGesturesEnabled = rotationGesturesEnabled, + scrollGesturesEnabled = scrollGesturesEnabled, + tiltGesturesEnabled = tiltGesturesEnabled, + zoomGesturesEnabled = zoomGesturesEnabled, + // iOS has no zoom buttons; hide them so both platforms look the same. + zoomControlsEnabled = false, +) + +/** The hue, in degrees, that tints Android's default marker to this color. */ +private fun Color.hue(): Float { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(toArgb(), hsv) + return hsv[0] +} + +/** Widths are density-independent in the shared API; maps-compose takes pixels. */ +@Composable +private fun Float.dpToPx(): Float = with(LocalDensity.current) { this@dpToPx.dp.toPx() } diff --git a/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionState.kt b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionState.kt new file mode 100644 index 000000000..32c2363b2 --- /dev/null +++ b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionState.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.google.maps.android.model.CameraPosition +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.bearing +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import com.google.maps.android.model.target +import com.google.maps.android.model.tilt +import com.google.maps.android.model.zoom + +/** + * State object that can be hoisted to control and observe the camera of a [GoogleMap]. + * + * The state follows the map both ways: setting [position] or calling [animate] moves the map, and + * the user's gestures update [position] and [isMoving]. + * + * @param position the initial camera position, used until the map reports its own + */ +public class CameraPositionState( + position: CameraPosition = CameraPosition(LatLng(0.0, 0.0), 0f, 0f, 0f), +) { + private var rawPosition by mutableStateOf(position) + + /** Whether the camera is currently moving, from a gesture or an animation. */ + public var isMoving: Boolean by mutableStateOf(false) + internal set + + /** + * The current camera position. Setting it moves the camera immediately; use [animate] to + * move it gradually. + */ + public var position: CameraPosition + get() = rawPosition + set(value) { + val controller = controller + if (controller == null) rawPosition = value else controller.move(value) + } + + /** Set by the map while it is shown, so that moves reach the platform map. */ + internal var controller: CameraController? = null + + /** + * Animates the camera to [position] over [durationMs] milliseconds, suspending until the + * animation ends. Without a map attached, the position is applied immediately. + */ + public suspend fun animate(position: CameraPosition, durationMs: Int = DEFAULT_ANIMATION_MS) { + val controller = controller + if (controller == null) rawPosition = position else controller.animate(position, durationMs) + } + + /** Called by the platform map when its camera changes, without moving it again. */ + internal fun onCameraChanged(position: CameraPosition, isMoving: Boolean) { + rawPosition = position + this.isMoving = isMoving + } + + public companion object { + internal const val DEFAULT_ANIMATION_MS: Int = 1_000 + + /** Saves and restores the camera position across configuration and process changes. */ + public val Saver: Saver = listSaver( + save = { + val position = it.position + listOf( + position.target.latitude, + position.target.longitude, + position.zoom.toDouble(), + position.tilt.toDouble(), + position.bearing.toDouble(), + ) + }, + restore = { values -> + CameraPositionState( + CameraPosition( + LatLng(values[0], values[1]), + values[2].toFloat(), + values[3].toFloat(), + values[4].toFloat(), + ) + ) + }, + ) + } +} + +/** Moves the platform map's camera on behalf of a [CameraPositionState]. */ +internal interface CameraController { + fun move(position: CameraPosition) + + suspend fun animate(position: CameraPosition, durationMs: Int) +} + +/** + * Creates and remembers a [CameraPositionState], saved across configuration changes. + * + * @param init applied once to the new state, for example to set its initial [position] + */ +@Composable +public inline fun rememberCameraPositionState( + crossinline init: CameraPositionState.() -> Unit = {}, +): CameraPositionState = rememberSaveable(saver = CameraPositionState.Saver) { + CameraPositionState().apply(init) +} + +/** Shorthand for a camera position looking straight down at [target]. */ +public fun cameraPosition(target: LatLng, zoom: Float): CameraPosition = + CameraPosition(target, zoom, 0f, 0f) diff --git a/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/Clustering.kt b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/Clustering.kt new file mode 100644 index 000000000..6713b74b1 --- /dev/null +++ b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/Clustering.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.Color +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.model.zoom +import kotlin.math.floor +import kotlinx.coroutines.launch + +/** + * Groups [items] that are close together on screen into clusters, at the given [zoom]. + * + * This is the algorithm [Clustering] uses, exposed for code that draws clusters itself. + * + * @param maxDistanceBetweenClusteredItems the largest distance, in density-independent pixels, + * between items that share a cluster + */ +public fun clusterItems( + items: Collection, + zoom: Float, + maxDistanceBetweenClusteredItems: Int = DEFAULT_MAX_DISTANCE, +): List> { + if (items.isEmpty()) return emptyList() + val algorithm = NonHierarchicalDistanceBasedAlgorithm() + algorithm.maxDistanceBetweenClusteredItems = maxDistanceBetweenClusteredItems + algorithm.addItems(items) + return algorithm.getClusters(zoom).toList() +} + +/** + * Draws [items] as markers, grouping the ones close together on screen into a single cluster + * marker. Clusters are recomputed as the camera zooms, once per whole zoom level. + * + * Must be called from the content of a [GoogleMap]. + * + * @param onClusterClick called when a cluster marker is tapped. Return true to consume the tap; + * otherwise the camera zooms in on the cluster. + * @param onClusterItemClick called when the marker of a single item is tapped. Return true to + * consume the tap and skip showing its info window. + * @param clusterTitle the info window title of a cluster marker + * @param clusterColor the pin color of cluster markers, to tell them apart from single items + * @param maxDistanceBetweenClusteredItems the largest distance, in density-independent pixels, + * between items that share a cluster + */ +@Composable +public fun Clustering( + items: Collection, + onClusterClick: (Cluster) -> Boolean = { false }, + onClusterItemClick: (T) -> Boolean = { false }, + clusterTitle: (Cluster) -> String = { "${it.size} items" }, + clusterColor: Color = DefaultClusterColor, + maxDistanceBetweenClusteredItems: Int = DEFAULT_MAX_DISTANCE, +) { + val cameraPositionState = checkNotNull(LocalCameraPositionState.current) { + "Clustering must be called from the content of a GoogleMap." + } + // The algorithm only distinguishes whole zoom levels, so skip the fractional changes of a + // pinch or an animation. + val zoomLevel by remember(cameraPositionState) { + derivedStateOf { floor(cameraPositionState.position.zoom) } + } + val clusters = remember(items, zoomLevel, maxDistanceBetweenClusteredItems) { + clusterItems(items, zoomLevel, maxDistanceBetweenClusteredItems) + } + val scope = rememberCoroutineScope() + + clusters.forEach { cluster -> + if (cluster.size == 1) { + val item = cluster.items.first() + key(item) { + Marker( + state = rememberMarkerState(item.position), + title = item.title, + snippet = item.snippet, + zIndex = item.zIndex ?: 0f, + onClick = { onClusterItemClick(item) }, + ) + } + } else { + // A cluster has no identity across zoom levels, so key it by its position and size. + key(cluster.position, cluster.size) { + Marker( + state = rememberMarkerState(cluster.position), + title = clusterTitle(cluster), + color = clusterColor, + onClick = { + if (!onClusterClick(cluster)) { + scope.launch { + cameraPositionState.animate( + cameraPosition(cluster.position, zoomLevel + CLUSTER_ZOOM_STEP), + ) + } + } + true + }, + ) + } + } + } +} + +private const val DEFAULT_MAX_DISTANCE = 100 + +private val DefaultClusterColor = Color(0xFF1A73E8) + +/** How far a tap on a cluster zooms in: enough to split most clusters. */ +private const val CLUSTER_ZOOM_STEP = 2f diff --git a/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt new file mode 100644 index 000000000..e96e9d4ed --- /dev/null +++ b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.google.maps.android.model.LatLng + +/** + * A Google Map, on Android through maps-compose and on iOS through the Google Maps SDK for iOS. + * + * The API follows maps-compose, so code written against it ports between the two with few + * changes. Draw on the map with [Marker], [Polyline], [Polygon], [Circle] and [Clustering] in + * [content]. + * + * On iOS, the app must call `GMSServices.provideAPIKey` before showing a map. + * + * @param cameraPositionState controls and observes the camera + * @param properties the map's content properties + * @param uiSettings the map's controls and gestures + * @param onMapClick called with the tapped location, when the tap hits no marker or shape + * @param onMapLongClick called with the long-pressed location + * @param onMapLoaded called once the first tiles have rendered + * @param content the markers, shapes and clusters to draw on the map + */ +@Composable +public fun GoogleMap( + modifier: Modifier = Modifier, + cameraPositionState: CameraPositionState = rememberCameraPositionState(), + properties: MapProperties = MapProperties(), + uiSettings: MapUiSettings = MapUiSettings(), + onMapClick: (LatLng) -> Unit = {}, + onMapLongClick: (LatLng) -> Unit = {}, + onMapLoaded: () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + val registry = remember { MapNodeRegistry() } + Box(modifier) { + PlatformGoogleMap( + modifier = Modifier.matchParentSize(), + cameraPositionState = cameraPositionState, + properties = properties, + uiSettings = uiSettings, + onMapClick = onMapClick, + onMapLongClick = onMapLongClick, + onMapLoaded = onMapLoaded, + registry = registry, + ) + // Map content emits no layout: it only registers nodes, which the platform map draws. + CompositionLocalProvider( + LocalMapNodeRegistry provides registry, + LocalCameraPositionState provides cameraPositionState, + ) { + content() + } + } +} + +/** Shows the platform map and draws the nodes in [registry] on it. */ +@Composable +internal expect fun PlatformGoogleMap( + modifier: Modifier, + cameraPositionState: CameraPositionState, + properties: MapProperties, + uiSettings: MapUiSettings, + onMapClick: (LatLng) -> Unit, + onMapLongClick: (LatLng) -> Unit, + onMapLoaded: () -> Unit, + registry: MapNodeRegistry, +) diff --git a/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapContent.kt b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapContent.kt new file mode 100644 index 000000000..30d248c58 --- /dev/null +++ b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapContent.kt @@ -0,0 +1,262 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import com.google.maps.android.model.LatLng + +/** + * The position of a [Marker], hoisted so that it can be moved from code and, for a draggable + * marker, follow the user's drag. + */ +@Stable +public class MarkerState(position: LatLng) { + /** The marker's position. Updated while the user drags a draggable marker. */ + public var position: LatLng by mutableStateOf(position) +} + +/** Creates and remembers a [MarkerState] at [position]. */ +@Composable +public fun rememberMarkerState(position: LatLng): MarkerState = remember { MarkerState(position) } + +/** + * A marker on the map, shown with the platform's default pin. Tapping it shows an info window + * with [title] and [snippet], unless [onClick] returns true. + * + * Must be called from the content of a [GoogleMap]. + * + * @param color tints the platform's default pin, or null for its standard red + * @param onClick called when the marker is tapped. Return true to consume the tap and skip the + * default behavior of centering the marker and showing its info window. + */ +@Composable +public fun Marker( + state: MarkerState, + title: String? = null, + snippet: String? = null, + color: Color? = null, + alpha: Float = 1f, + draggable: Boolean = false, + flat: Boolean = false, + rotation: Float = 0f, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: (MarkerState) -> Boolean = { false }, +) { + val node = rememberNode { MarkerNode(state) } + SideEffect { + node.state = state + node.title = title + node.snippet = snippet + node.color = color + node.alpha = alpha + node.draggable = draggable + node.flat = flat + node.rotation = rotation + node.visible = visible + node.zIndex = zIndex + node.onClick = onClick + } +} + +/** + * A line through [points]. + * + * @param width the line width in density-independent pixels, so it looks the same on Android and + * iOS + * @param geodesic whether each segment follows the shortest path on the Earth's surface instead + * of a straight line on the map + * @param onClick called when the line is tapped; only when [clickable] + */ +@Composable +public fun Polyline( + points: List, + color: Color = Color.Black, + width: Float = DEFAULT_STROKE_WIDTH, + geodesic: Boolean = false, + clickable: Boolean = false, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: () -> Unit = {}, +) { + val node = rememberNode { PolylineNode() } + SideEffect { + node.points = points + node.color = color + node.width = width + node.geodesic = geodesic + node.clickable = clickable + node.visible = visible + node.zIndex = zIndex + node.onClick = onClick + } +} + +/** + * A filled shape with its outline at [points], minus any [holes]. + * + * @param strokeWidth the outline width in density-independent pixels + * @param onClick called when the polygon is tapped; only when [clickable] + */ +@Composable +public fun Polygon( + points: List, + holes: List> = emptyList(), + fillColor: Color = Color.Black, + strokeColor: Color = Color.Black, + strokeWidth: Float = DEFAULT_STROKE_WIDTH, + geodesic: Boolean = false, + clickable: Boolean = false, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: () -> Unit = {}, +) { + val node = rememberNode { PolygonNode() } + SideEffect { + node.points = points + node.holes = holes + node.fillColor = fillColor + node.strokeColor = strokeColor + node.strokeWidth = strokeWidth + node.geodesic = geodesic + node.clickable = clickable + node.visible = visible + node.zIndex = zIndex + node.onClick = onClick + } +} + +/** + * A circle of [radius] meters around [center]. + * + * @param strokeWidth the outline width in density-independent pixels + * @param onClick called when the circle is tapped; only when [clickable] + */ +@Composable +public fun Circle( + center: LatLng, + radius: Double, + fillColor: Color = Color.Transparent, + strokeColor: Color = Color.Black, + strokeWidth: Float = DEFAULT_STROKE_WIDTH, + clickable: Boolean = false, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: () -> Unit = {}, +) { + val node = rememberNode { CircleNode(center) } + SideEffect { + node.center = center + node.radius = radius + node.fillColor = fillColor + node.strokeColor = strokeColor + node.strokeWidth = strokeWidth + node.clickable = clickable + node.visible = visible + node.zIndex = zIndex + node.onClick = onClick + } +} + +/** + * Something drawn on the map. Each content composable owns one node for as long as it is in the + * composition, so the platform map keeps one native object per node and only updates what + * changed. Properties are snapshot state, so reading them in the platform map's update observes + * them. + */ +internal sealed class MapNode { + var visible by mutableStateOf(true) + var zIndex by mutableStateOf(0f) +} + +internal class MarkerNode(state: MarkerState) : MapNode() { + var state by mutableStateOf(state) + var title by mutableStateOf(null) + var snippet by mutableStateOf(null) + var color by mutableStateOf(null) + var alpha by mutableStateOf(1f) + var draggable by mutableStateOf(false) + var flat by mutableStateOf(false) + var rotation by mutableStateOf(0f) + var onClick: (MarkerState) -> Boolean = { false } +} + +internal class PolylineNode : MapNode() { + var points by mutableStateOf(emptyList()) + var color by mutableStateOf(Color.Black) + var width by mutableStateOf(10f) + var geodesic by mutableStateOf(false) + var clickable by mutableStateOf(false) + var onClick: () -> Unit = {} +} + +internal class PolygonNode : MapNode() { + var points by mutableStateOf(emptyList()) + var holes by mutableStateOf(emptyList>()) + var fillColor by mutableStateOf(Color.Black) + var strokeColor by mutableStateOf(Color.Black) + var strokeWidth by mutableStateOf(10f) + var geodesic by mutableStateOf(false) + var clickable by mutableStateOf(false) + var onClick: () -> Unit = {} +} + +internal class CircleNode(center: LatLng) : MapNode() { + var center by mutableStateOf(center) + var radius by mutableStateOf(0.0) + var fillColor by mutableStateOf(Color.Transparent) + var strokeColor by mutableStateOf(Color.Black) + var strokeWidth by mutableStateOf(10f) + var clickable by mutableStateOf(false) + var onClick: () -> Unit = {} +} + +/** The nodes of one map, in the order they entered the composition. */ +internal class MapNodeRegistry { + val nodes = mutableStateListOf() +} + +/** About the width of the platforms' own default lines, in density-independent pixels. */ +private const val DEFAULT_STROKE_WIDTH = 4f + +internal val LocalMapNodeRegistry = staticCompositionLocalOf { null } + +internal val LocalCameraPositionState = staticCompositionLocalOf { null } + +/** Remembers a node and keeps it in the enclosing map's registry while in the composition. */ +@Composable +private inline fun rememberNode(crossinline create: () -> T): T { + val registry = checkNotNull(LocalMapNodeRegistry.current) { + "Map content such as Marker must be called from the content of a GoogleMap." + } + val node = remember { create() } + DisposableEffect(registry, node) { + registry.nodes.add(node) + onDispose { registry.nodes.remove(node) } + } + return node +} diff --git a/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapProperties.kt b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapProperties.kt new file mode 100644 index 000000000..89e82473f --- /dev/null +++ b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapProperties.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.Immutable + +/** The type of map tiles to display. */ +public enum class MapType { + NONE, + NORMAL, + SATELLITE, + TERRAIN, + HYBRID, +} + +/** + * Properties of the map content, as opposed to the controls in [MapUiSettings]. + * + * @param mapType the type of map tiles + * @param isMyLocationEnabled whether to show the user's location. The app must hold the location + * permission on Android and the location authorization on iOS. + * @param isBuildingEnabled whether to show 3D buildings + * @param isTrafficEnabled whether to show traffic + * @param isIndoorEnabled whether to show indoor maps + * @param mapStyleJson a JSON map style, see https://developers.google.com/maps/documentation/android-sdk/styling. + * Ignored for maps with a map ID. + * @param minZoomPreference the lowest zoom the camera can reach + * @param maxZoomPreference the highest zoom the camera can reach + */ +@Immutable +public class MapProperties( + public val mapType: MapType = MapType.NORMAL, + public val isMyLocationEnabled: Boolean = false, + public val isBuildingEnabled: Boolean = false, + public val isTrafficEnabled: Boolean = false, + public val isIndoorEnabled: Boolean = false, + public val mapStyleJson: String? = null, + public val minZoomPreference: Float = 3f, + public val maxZoomPreference: Float = 21f, +) { + public fun copy( + mapType: MapType = this.mapType, + isMyLocationEnabled: Boolean = this.isMyLocationEnabled, + isBuildingEnabled: Boolean = this.isBuildingEnabled, + isTrafficEnabled: Boolean = this.isTrafficEnabled, + isIndoorEnabled: Boolean = this.isIndoorEnabled, + mapStyleJson: String? = this.mapStyleJson, + minZoomPreference: Float = this.minZoomPreference, + maxZoomPreference: Float = this.maxZoomPreference, + ): MapProperties = MapProperties( + mapType, isMyLocationEnabled, isBuildingEnabled, isTrafficEnabled, isIndoorEnabled, + mapStyleJson, minZoomPreference, maxZoomPreference, + ) + + private val fields: List + get() = listOf( + mapType, isMyLocationEnabled, isBuildingEnabled, isTrafficEnabled, isIndoorEnabled, + mapStyleJson, minZoomPreference, maxZoomPreference, + ) + + override fun equals(other: Any?): Boolean = other is MapProperties && fields == other.fields + + override fun hashCode(): Int = fields.hashCode() + + override fun toString(): String = "MapProperties$fields" +} + +/** + * The map's built-in controls and gestures. + * + * Android also has zoom buttons, which stay hidden so that both platforms look the same. + */ +@Immutable +public class MapUiSettings( + public val compassEnabled: Boolean = true, + public val myLocationButtonEnabled: Boolean = true, + public val rotationGesturesEnabled: Boolean = true, + public val scrollGesturesEnabled: Boolean = true, + public val tiltGesturesEnabled: Boolean = true, + public val zoomGesturesEnabled: Boolean = true, +) { + public fun copy( + compassEnabled: Boolean = this.compassEnabled, + myLocationButtonEnabled: Boolean = this.myLocationButtonEnabled, + rotationGesturesEnabled: Boolean = this.rotationGesturesEnabled, + scrollGesturesEnabled: Boolean = this.scrollGesturesEnabled, + tiltGesturesEnabled: Boolean = this.tiltGesturesEnabled, + zoomGesturesEnabled: Boolean = this.zoomGesturesEnabled, + ): MapUiSettings = MapUiSettings( + compassEnabled, myLocationButtonEnabled, rotationGesturesEnabled, scrollGesturesEnabled, + tiltGesturesEnabled, zoomGesturesEnabled, + ) + + private val fields: List + get() = listOf( + compassEnabled, myLocationButtonEnabled, rotationGesturesEnabled, scrollGesturesEnabled, + tiltGesturesEnabled, zoomGesturesEnabled, + ) + + override fun equals(other: Any?): Boolean = other is MapUiSettings && fields == other.fields + + override fun hashCode(): Int = fields.hashCode() + + override fun toString(): String = "MapUiSettings$fields" +} diff --git a/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionStateTest.kt b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionStateTest.kt new file mode 100644 index 000000000..52c691c27 --- /dev/null +++ b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionStateTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.saveable.SaverScope +import com.google.maps.android.model.CameraPosition +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.bearing +import com.google.maps.android.model.target +import com.google.maps.android.model.tilt +import com.google.maps.android.model.zoom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class CameraPositionStateTest { + private val start = CameraPosition(LatLng(37.7749, -122.4194), 12f, 30f, 90f) + private val end = cameraPosition(LatLng(51.5007, -0.1246), 14f) + + /** Records moves the way a platform map would receive them. */ + private class FakeController : CameraController { + val moves = mutableListOf() + val animations = mutableListOf>() + + override fun move(position: CameraPosition) { + moves += position + } + + override suspend fun animate(position: CameraPosition, durationMs: Int) { + animations += position to durationMs + } + } + + @Test + fun withoutAMapSettingThePositionAppliesIt() { + val state = CameraPositionState(start) + + state.position = end + + assertSame(end, state.position) + } + + @Test + fun withAMapSettingThePositionMovesTheMapInstead() { + val state = CameraPositionState(start) + val controller = FakeController() + state.controller = controller + + state.position = end + + assertEquals(listOf(end), controller.moves) + // The position follows once the map reports its new camera. + assertSame(start, state.position) + state.onCameraChanged(end, isMoving = false) + assertSame(end, state.position) + } + + @Test + fun animateGoesThroughTheMap() = runTest { + val state = CameraPositionState(start) + val controller = FakeController() + state.controller = controller + + state.animate(end, durationMs = 250) + + assertEquals(listOf(end to 250), controller.animations) + } + + @Test + fun theMapReportsWhetherTheCameraIsMoving() { + val state = CameraPositionState(start) + + state.onCameraChanged(end, isMoving = true) + assertTrue(state.isMoving) + + state.onCameraChanged(end, isMoving = false) + assertFalse(state.isMoving) + } + + @Test + fun theSaverRestoresEveryCameraProperty() { + val scope = SaverScope { true } + val saved = with(CameraPositionState.Saver) { scope.save(CameraPositionState(start)) } + + val restored = assertNotNull(CameraPositionState.Saver.restore(assertNotNull(saved))) + + val position = restored.position + assertEquals(start.target, position.target) + assertEquals(start.zoom, position.zoom) + assertEquals(start.tilt, position.tilt) + assertEquals(start.bearing, position.bearing) + } + + @Test + fun mapPropertiesCompareByValue() { + val properties = MapProperties(mapType = MapType.SATELLITE) + + assertEquals(properties, MapProperties(mapType = MapType.SATELLITE)) + assertEquals(MapType.HYBRID, properties.copy(mapType = MapType.HYBRID).mapType) + assertEquals(MapUiSettings(zoomGesturesEnabled = false), MapUiSettings().copy(zoomGesturesEnabled = false)) + } +} diff --git a/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusteringTest.kt b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusteringTest.kt new file mode 100644 index 000000000..0e3d0e5e7 --- /dev/null +++ b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusteringTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2026 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.multiplatform + +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.model.LatLng +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Runs on the JVM and on the iOS simulator, so it also checks that the multiplatform clustering + * from android-maps-utils behaves the same on both. + */ +class ClusteringTest { + private class Place( + override val title: String, + latitude: Double, + longitude: Double, + ) : ClusterItem { + override val position: LatLng = LatLng(latitude, longitude) + override val snippet: String? = null + override val zIndex: Float? = null + } + + private val goldenGate = Place("Golden Gate", 37.8199, -122.4783) + private val alcatraz = Place("Alcatraz", 37.8267, -122.4230) + private val coitTower = Place("Coit Tower", 37.8024, -122.4058) + private val bigBen = Place("Big Ben", 51.5007, -0.1246) + private val sanFrancisco = listOf(goldenGate, alcatraz, coitTower) + + @Test + fun noItemsGiveNoClusters() { + assertTrue(clusterItems(emptyList(), zoom = 10f).isEmpty()) + } + + @Test + fun nearbyItemsShareAClusterWhenZoomedOut() { + val cluster = clusterItems(sanFrancisco, zoom = 5f).single() + + assertEquals(sanFrancisco.toSet(), cluster.items.toSet()) + } + + @Test + fun nearbyItemsSplitWhenZoomedIn() { + val clusters = clusterItems(sanFrancisco, zoom = 18f) + + assertEquals(3, clusters.size) + assertTrue(clusters.all { it.size == 1 }) + } + + @Test + fun distantItemsNeverShareACluster() { + assertEquals(2, clusterItems(listOf(goldenGate, bigBen), zoom = 3f).size) + } + + @Test + fun aLargerDistanceMergesMoreItems() { + assertEquals(3, clusterItems(sanFrancisco, zoom = 12f, maxDistanceBetweenClusteredItems = 1).size) + assertEquals(1, clusterItems(sanFrancisco, zoom = 12f, maxDistanceBetweenClusteredItems = 1_000).size) + } + + @Test + fun clusteringDrawsOneMarkerPerCluster() { + val camera = CameraPositionState(cameraPosition(goldenGate.position, zoom = 5f)) + runMapContentTest(camera, content = { Clustering(sanFrancisco) }) { + val marker = nodes.single() as MarkerNode + assertEquals("3 items", marker.title) + } + } + + @Test + fun clusteringSplitsWhenTheCameraZoomsIn() { + val camera = CameraPositionState(cameraPosition(goldenGate.position, zoom = 5f)) + runMapContentTest(camera, content = { Clustering(sanFrancisco) }) { + assertEquals(1, nodes.size) + + // As the platform map reports a gesture. + camera.onCameraChanged(cameraPosition(goldenGate.position, zoom = 18f), isMoving = false) + awaitIdle() + + val titles = nodes.map { (it as MarkerNode).title }.toSet() + assertEquals(setOf("Golden Gate", "Alcatraz", "Coit Tower"), titles) + } + } + + @Test + fun clusteringUsesTheGivenClusterTitle() { + val camera = CameraPositionState(cameraPosition(goldenGate.position, zoom = 5f)) + runMapContentTest(camera, content = { + Clustering(sanFrancisco, clusterTitle = { "${it.size} places" }) + }) { + assertEquals("3 places", (nodes.single() as MarkerNode).title) + } + } +} diff --git a/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTest.kt b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTest.kt new file mode 100644 index 000000000..5cc8436f9 --- /dev/null +++ b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import com.google.maps.android.model.LatLng +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class MapContentTest { + private val sanFrancisco = LatLng(37.7749, -122.4194) + private val oakland = LatLng(37.8044, -122.2712) + + @Test + fun aMarkerRegistersOneNodeWithItsProperties() { + val state = MarkerState(sanFrancisco) + runMapContentTest(content = { + Marker(state = state, title = "SF", snippet = "Welcome", draggable = true, zIndex = 2f) + }) { + val node = nodes.single() as MarkerNode + assertSame(state, node.state) + assertEquals("SF", node.title) + assertEquals("Welcome", node.snippet) + assertTrue(node.draggable) + assertEquals(2f, node.zIndex) + } + } + + @Test + fun changingAPropertyUpdatesTheSameNode() { + var title by mutableStateOf("Before") + runMapContentTest(content = { + Marker(state = rememberMarkerState(sanFrancisco), title = title) + }) { + val node = nodes.single() as MarkerNode + + title = "After" + awaitIdle() + + assertSame(node, nodes.single()) + assertEquals("After", node.title) + } + } + + @Test + fun contentLeavingTheCompositionUnregistersItsNode() { + var showCircle by mutableStateOf(true) + runMapContentTest(content = { + Marker(state = rememberMarkerState(sanFrancisco)) + if (showCircle) Circle(center = oakland, radius = 1_000.0) + }) { + assertEquals(2, nodes.size) + + showCircle = false + awaitIdle() + + assertTrue(nodes.single() is MarkerNode) + } + } + + @Test + fun shapesRegisterTheirGeometryAndStyle() { + val points = listOf(sanFrancisco, oakland) + runMapContentTest(content = { + Polyline(points = points, color = Color.Red, width = 4f, clickable = true) + Polygon(points = points + LatLng(37.7, -122.3), fillColor = Color.Blue) + Circle(center = oakland, radius = 500.0, strokeColor = Color.Green) + }) { + val polyline = nodes[0] as PolylineNode + val polygon = nodes[1] as PolygonNode + val circle = nodes[2] as CircleNode + assertEquals(points, polyline.points) + assertEquals(Color.Red, polyline.color) + assertTrue(polyline.clickable) + assertEquals(3, polygon.points.size) + assertEquals(Color.Blue, polygon.fillColor) + assertEquals(oakland, circle.center) + assertEquals(500.0, circle.radius) + assertEquals(Color.Green, circle.strokeColor) + } + } + + @Test + fun aMarkerClickReachesItsCallbackWithItsState() { + val state = MarkerState(sanFrancisco) + var clicked: MarkerState? = null + runMapContentTest(content = { + Marker(state = state, onClick = { clicked = it; true }) + }) { + val node = nodes.single() as MarkerNode + + val consumed = node.onClick(node.state) + + assertTrue(consumed) + assertSame(state, clicked) + } + } +} diff --git a/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTestHarness.kt b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTestHarness.kt new file mode 100644 index 000000000..0c499567c --- /dev/null +++ b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTestHarness.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield + +/** + * Composes map content without a platform map, the way [GoogleMap] does, so that tests can + * check which nodes the content registers. Runs on every target. + */ +internal class MapContentTestHarness( + private val clock: BroadcastFrameClock, + val cameraPositionState: CameraPositionState, +) { + val registry = MapNodeRegistry() + private var frameTime = 0L + + val nodes: List get() = registry.nodes.toList() + + fun setContent(composition: Composition, content: @Composable () -> Unit) { + composition.setContent { + CompositionLocalProvider( + LocalMapNodeRegistry provides registry, + LocalCameraPositionState provides cameraPositionState, + ) { + content() + } + } + } + + /** Applies pending state changes and lets the recomposer run a frame. */ + suspend fun awaitIdle() { + repeat(3) { + Snapshot.sendApplyNotifications() + frameTime += 16_000_000L + clock.sendFrame(frameTime) + yield() + } + } +} + +private class UnitApplier : AbstractApplier(Unit) { + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun onClear() = Unit +} + +internal fun runMapContentTest( + cameraPositionState: CameraPositionState = CameraPositionState(), + content: @Composable () -> Unit, + block: suspend MapContentTestHarness.() -> Unit, +) = runTest { + val clock = BroadcastFrameClock() + val recomposer = Recomposer(coroutineContext + clock) + val recomposeJob = launch(clock) { recomposer.runRecomposeAndApplyChanges() } + val composition = Composition(UnitApplier(), recomposer) + val harness = MapContentTestHarness(clock, cameraPositionState) + try { + harness.setContent(composition, content) + harness.awaitIdle() + harness.block() + } finally { + composition.dispose() + recomposer.cancel() + recomposeJob.cancel() + } +} diff --git a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapNodeRenderer.kt b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapNodeRenderer.kt new file mode 100644 index 000000000..72d805361 --- /dev/null +++ b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapNodeRenderer.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.snapshots.SnapshotStateObserver +import androidx.compose.ui.graphics.Color +import cocoapods.GoogleMaps.GMSCircle +import cocoapods.GoogleMaps.GMSMapView +import cocoapods.GoogleMaps.GMSMarker +import cocoapods.GoogleMaps.GMSMutablePath +import cocoapods.GoogleMaps.GMSOverlay +import cocoapods.GoogleMaps.GMSPath +import cocoapods.GoogleMaps.GMSPolygon +import cocoapods.GoogleMaps.GMSPolyline +import com.google.maps.android.model.LatLng +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSOperationQueue +import platform.UIKit.UIColor + +/** + * Keeps one native overlay per [MapNode] in [registry] on [mapView], creating, updating and + * removing them as the nodes change. + * + * Every snapshot state read while syncing is observed, so a change to the node list or to any + * node's properties schedules another sync on the main thread. Each sync only touches the + * overlays' properties; overlays are never recreated, so markers keep their info windows and + * drags. + */ +@OptIn(ExperimentalForeignApi::class) +internal class MapNodeRenderer( + private val mapView: GMSMapView, + private val registry: MapNodeRegistry, +) { + private val overlays = mutableMapOf() + private val nodesByOverlay = mutableMapOf() + + /** The color each marker's icon was drawn with, so the image is only rebuilt on a change. */ + private val markerColors = mutableMapOf() + + private val observer = SnapshotStateObserver { command -> + NSOperationQueue.mainQueue.addOperationWithBlock(command) + } + private val onChanged: (MapNodeRenderer) -> Unit = { sync() } + + fun start() { + observer.start() + sync() + } + + fun stop() { + observer.stop() + observer.clear() + overlays.values.forEach { it.map = null } + overlays.clear() + nodesByOverlay.clear() + markerColors.clear() + } + + fun nodeFor(overlay: GMSOverlay): MapNode? = nodesByOverlay[overlay] + + fun onMarkerDragged(marker: GMSMarker) { + val node = nodesByOverlay[marker] as? MarkerNode ?: return + node.state.position = marker.position.toLatLng() + } + + private fun sync() { + observer.observeReads(this, onChanged) { + val nodes = registry.nodes.toList() + val removed = overlays.keys - nodes.toSet() + removed.forEach { node -> + overlays.remove(node)?.let { overlay -> + overlay.map = null + nodesByOverlay.remove(overlay) + } + markerColors.remove(node) + } + nodes.forEach { node -> + val overlay = overlays.getOrPut(node) { + create(node).also { nodesByOverlay[it] = node } + } + update(node, overlay) + } + } + } + + private fun create(node: MapNode): GMSOverlay = when (node) { + is MarkerNode -> GMSMarker() + is PolylineNode -> GMSPolyline() + is PolygonNode -> GMSPolygon() + is CircleNode -> GMSCircle() + } + + private fun update(node: MapNode, overlay: GMSOverlay) { + when (node) { + is MarkerNode -> (overlay as GMSMarker).apply { + position = node.state.position.toCoordinate() + title = node.title + snippet = node.snippet + val color = node.color + if (!markerColors.containsKey(node) || markerColors[node] != color) { + markerColors[node] = color + icon = color?.let { GMSMarker.markerImageWithColor(it.toUIColor()) } + } + opacity = node.alpha + draggable = node.draggable + flat = node.flat + rotation = node.rotation.toDouble() + } + is PolylineNode -> (overlay as GMSPolyline).apply { + path = node.points.toPath() + strokeColor = node.color.toUIColor() + // Points, which are density-independent like the shared API's widths. + strokeWidth = node.width.toDouble() + geodesic = node.geodesic + tappable = node.clickable + } + is PolygonNode -> (overlay as GMSPolygon).apply { + path = node.points.toPath() + holes = node.holes.map { it.toPath() } + fillColor = node.fillColor.toUIColor() + strokeColor = node.strokeColor.toUIColor() + strokeWidth = node.strokeWidth.toDouble() + geodesic = node.geodesic + tappable = node.clickable + } + is CircleNode -> (overlay as GMSCircle).apply { + position = node.center.toCoordinate() + radius = node.radius + fillColor = node.fillColor.toUIColor() + strokeColor = node.strokeColor.toUIColor() + strokeWidth = node.strokeWidth.toDouble() + tappable = node.clickable + } + } + overlay.zIndex = node.zIndex.toInt() + overlay.map = if (node.visible) mapView else null + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun List.toPath(): GMSPath = GMSMutablePath().also { path -> + forEach { path.addCoordinate(it.toCoordinate()) } +} + +private fun Color.toUIColor(): UIColor = UIColor.colorWithRed( + red = red.toDouble(), + green = green.toDouble(), + blue = blue.toDouble(), + alpha = alpha.toDouble(), +) diff --git a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapViewDelegate.kt b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapViewDelegate.kt new file mode 100644 index 000000000..4776891fb --- /dev/null +++ b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapViewDelegate.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2026 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.multiplatform + +import cocoapods.GoogleMaps.GMSCameraPosition +import cocoapods.GoogleMaps.GMSMapView +import cocoapods.GoogleMaps.GMSMapViewDelegateProtocol +import cocoapods.GoogleMaps.GMSMarker +import cocoapods.GoogleMaps.GMSOverlay +import cocoapods.GoogleMaps.animateToCameraPosition +import com.google.maps.android.model.CameraPosition +import com.google.maps.android.model.LatLng +import kotlin.coroutines.resume +import kotlinx.cinterop.CValue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride +import kotlinx.cinterop.useContents +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.CoreLocation.CLLocationCoordinate2D +import platform.CoreLocation.CLLocationCoordinate2DMake +import platform.QuartzCore.CATransaction +import platform.darwin.NSObject + +/** Forwards the map's events to the camera state, the drawn nodes and the map's callbacks. */ +@OptIn(ExperimentalForeignApi::class) +internal class MapViewDelegate( + private val cameraPositionState: CameraPositionState, + private val renderer: MapNodeRenderer, + private val onMapClick: (LatLng) -> Unit, + private val onMapLongClick: (LatLng) -> Unit, + private val onMapLoaded: () -> Unit, +) : NSObject(), GMSMapViewDelegateProtocol { + private var loaded = false + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, didChangeCameraPosition: GMSCameraPosition) { + cameraPositionState.onCameraChanged(didChangeCameraPosition.toShared(), isMoving = true) + } + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, idleAtCameraPosition: GMSCameraPosition) { + cameraPositionState.onCameraChanged(idleAtCameraPosition.toShared(), isMoving = false) + } + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, didTapAtCoordinate: CValue) { + onMapClick(didTapAtCoordinate.toLatLng()) + } + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, didLongPressAtCoordinate: CValue) { + onMapLongClick(didLongPressAtCoordinate.toLatLng()) + } + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, didTapMarker: GMSMarker): Boolean { + val node = renderer.nodeFor(didTapMarker) as? MarkerNode ?: return false + return node.onClick(node.state) + } + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, didDragMarker: GMSMarker) { + renderer.onMarkerDragged(didDragMarker) + } + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, didEndDraggingMarker: GMSMarker) { + renderer.onMarkerDragged(didEndDraggingMarker) + } + + @ObjCSignatureOverride + override fun mapView(mapView: GMSMapView, didTapOverlay: GMSOverlay) { + when (val node = renderer.nodeFor(didTapOverlay)) { + is PolylineNode -> node.onClick() + is PolygonNode -> node.onClick() + is CircleNode -> node.onClick() + else -> Unit + } + } + + override fun mapViewDidFinishTileRendering(mapView: GMSMapView) { + // Tiles finish rendering after every camera move; report only the first time. + if (!loaded) { + loaded = true + onMapLoaded() + } + } +} + +/** Moves a [GMSMapView]'s camera for a [CameraPositionState]. */ +@OptIn(ExperimentalForeignApi::class) +internal class MapViewCameraController(private val mapView: GMSMapView) : CameraController { + override fun move(position: CameraPosition) { + mapView.camera = position.toGms() + } + + override suspend fun animate(position: CameraPosition, durationMs: Int) { + suspendCancellableCoroutine { continuation -> + CATransaction.begin() + CATransaction.setAnimationDuration(durationMs / 1_000.0) + CATransaction.setCompletionBlock { + if (continuation.isActive) continuation.resume(Unit) + } + mapView.animateToCameraPosition(position.toGms()) + CATransaction.commit() + } + } +} + +@OptIn(ExperimentalForeignApi::class) +internal fun LatLng.toCoordinate(): CValue = + CLLocationCoordinate2DMake(latitude, longitude) + +@OptIn(ExperimentalForeignApi::class) +internal fun CValue.toLatLng(): LatLng = useContents { LatLng(latitude, longitude) } diff --git a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.ios.kt b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.ios.kt new file mode 100644 index 000000000..27ea54613 --- /dev/null +++ b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.ios.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2026 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.multiplatform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode +import androidx.compose.ui.viewinterop.UIKitInteropProperties +import androidx.compose.ui.viewinterop.UIKitView +import cocoapods.GoogleMaps.GMSCameraPosition +import cocoapods.GoogleMaps.GMSMapStyle +import cocoapods.GoogleMaps.GMSMapView +import cocoapods.GoogleMaps.kGMSTypeHybrid +import cocoapods.GoogleMaps.kGMSTypeNone +import cocoapods.GoogleMaps.kGMSTypeNormal +import cocoapods.GoogleMaps.kGMSTypeSatellite +import cocoapods.GoogleMaps.kGMSTypeTerrain +import com.google.maps.android.model.CameraPosition +import com.google.maps.android.model.LatLng +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero + +@OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) +@Composable +internal actual fun PlatformGoogleMap( + modifier: Modifier, + cameraPositionState: CameraPositionState, + properties: MapProperties, + uiSettings: MapUiSettings, + onMapClick: (LatLng) -> Unit, + onMapLongClick: (LatLng) -> Unit, + onMapLoaded: () -> Unit, + registry: MapNodeRegistry, +) { + val currentOnMapClick by rememberUpdatedState(onMapClick) + val currentOnMapLongClick by rememberUpdatedState(onMapLongClick) + val currentOnMapLoaded by rememberUpdatedState(onMapLoaded) + + val mapView = remember { + GMSMapView(frame = CGRectZero.readValue(), camera = cameraPositionState.position.toGms()) + } + val renderer = remember(mapView, registry) { MapNodeRenderer(mapView, registry) } + // GMSMapView holds its delegate weakly, so keep a strong reference here. + val delegate = remember(mapView, renderer, cameraPositionState) { + MapViewDelegate( + cameraPositionState = cameraPositionState, + renderer = renderer, + onMapClick = { currentOnMapClick(it) }, + onMapLongClick = { currentOnMapLongClick(it) }, + onMapLoaded = { currentOnMapLoaded() }, + ) + } + + DisposableEffect(mapView, delegate) { + mapView.delegate = delegate + onDispose { mapView.delegate = null } + } + DisposableEffect(mapView, cameraPositionState) { + // The map may have been moved before it was shown, for example by a saved state. + mapView.camera = cameraPositionState.position.toGms() + cameraPositionState.controller = MapViewCameraController(mapView) + onDispose { cameraPositionState.controller = null } + } + DisposableEffect(renderer) { + renderer.start() + onDispose { renderer.stop() } + } + + UIKitView( + factory = { mapView }, + modifier = modifier, + update = { view -> + view.applyProperties(properties) + view.applyUiSettings(uiSettings, properties.isMyLocationEnabled) + }, + properties = UIKitInteropProperties( + // The map handles its own gestures; do not let Compose delay or intercept them. + interactionMode = UIKitInteropInteractionMode.NonCooperative, + ), + ) +} + +@OptIn(ExperimentalForeignApi::class) +private fun GMSMapView.applyProperties(properties: MapProperties) { + mapType = when (properties.mapType) { + MapType.NONE -> kGMSTypeNone + MapType.NORMAL -> kGMSTypeNormal + MapType.SATELLITE -> kGMSTypeSatellite + MapType.TERRAIN -> kGMSTypeTerrain + MapType.HYBRID -> kGMSTypeHybrid + } + myLocationEnabled = properties.isMyLocationEnabled + buildingsEnabled = properties.isBuildingEnabled + trafficEnabled = properties.isTrafficEnabled + indoorEnabled = properties.isIndoorEnabled + mapStyle = properties.mapStyleJson?.let { GMSMapStyle.styleWithJSONString(it, error = null) } + setMinZoom(properties.minZoomPreference, maxZoom = properties.maxZoomPreference) +} + +@OptIn(ExperimentalForeignApi::class) +private fun GMSMapView.applyUiSettings(uiSettings: MapUiSettings, isMyLocationEnabled: Boolean) { + settings.compassButton = uiSettings.compassEnabled + // As on Android, the button only shows while the user's location does. + settings.myLocationButton = uiSettings.myLocationButtonEnabled && isMyLocationEnabled + settings.rotateGestures = uiSettings.rotationGesturesEnabled + settings.scrollGestures = uiSettings.scrollGesturesEnabled + settings.tiltGestures = uiSettings.tiltGesturesEnabled + settings.zoomGestures = uiSettings.zoomGesturesEnabled +} + +@OptIn(ExperimentalForeignApi::class) +internal fun CameraPosition.toGms(): GMSCameraPosition = GMSCameraPosition( + target = target.toCoordinate(), + zoom = zoom, + bearing = bearing.toDouble(), + viewingAngle = tilt.toDouble(), +) + +@OptIn(ExperimentalForeignApi::class) +internal fun GMSCameraPosition.toShared(): CameraPosition = CameraPosition( + target = target.toLatLng(), + zoom = zoom, + tilt = viewingAngle.toFloat(), + bearing = bearing.toFloat(), +) diff --git a/settings.gradle.kts b/settings.gradle.kts index e0b1768c2..34d57ccb5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,10 +19,21 @@ dependencyResolutionManagement { repositories { if (providers.gradleProperty("useMavenLocal").orNull == "true" || providers.environmentVariable("USE_MAVEN_LOCAL").orNull == "true") { - mavenLocal() + mavenLocal { + mavenContent { + includeGroup("com.google.maps.android") + } + } } google() mavenCentral() + // Snapshots of the android-maps-utils KMP branch, used by maps-compose-multiplatform. + maven("https://central.sonatype.com/repository/maven-snapshots/") { + mavenContent { + snapshotsOnly() + includeGroup("com.google.maps.android") + } + } } } pluginManagement { @@ -38,8 +49,34 @@ plugins { } rootProject.name = "android-maps-compose" +// The maps-compose-multiplatform module depends on the experimental KMP branch of +// android-maps-utils (feat/experimental-kmp-clustering), which provides multiplatform +// clustering algorithms and the common LatLng/CameraPosition model types. By default they come +// from the snapshot repository above, published with that repo's "Publish snapshot" workflow. +// +// To try unpublished changes, publish them to mavenLocal and build with -PuseMavenLocal=true: +// (in ../android-maps-utils) ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal +// +// -PuseLocalMapsUtils=true instead substitutes them with a composite build of +// ../android-maps-utils. With the composite, platform compilations (android/iOS) work but the +// shared-metadata compilation (compileCommonMainKotlinMetadata, and thus assemble) fails: +// Kotlin's granular metadata transformation skips project dependencies substituted across +// included builds, so common code cannot be analyzed against them. +if (providers.gradleProperty("useLocalMapsUtils").orNull == "true") { + includeBuild("../android-maps-utils") { + dependencySubstitution { + substitute(module("com.google.maps.android:android-maps-utils-clustering")) + .using(project(":clustering")) + substitute(module("com.google.maps.android:android-maps-utils-maps-model")) + .using(project(":maps-model")) + } + } +} + include(":maps-app") include(":maps-compose") include(":maps-compose-widgets") include(":maps-compose-utils") +include(":maps-compose-multiplatform") +include(":maps-compose-multiplatform-demo") include(":docs") \ No newline at end of file