From fd2212710e3bb1c9133ba9be4073d7cc68ca4617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 4 Jun 2026 09:07:34 +0200 Subject: [PATCH 01/14] feat: introduce experimental KMP maps module wrapping Google Maps on Android and MapKit on iOS --- maps-compose-multiplatform/build.gradle.kts | 55 +++++++++++++++++++ .../compose/multiplatform/GoogleMap.kt | 40 ++++++++++++++ .../compose/multiplatform/GoogleMap.kt | 32 +++++++++++ .../compose/multiplatform/GoogleMap.kt | 51 +++++++++++++++++ settings.gradle.kts | 1 + 5 files changed, 179 insertions(+) create mode 100644 maps-compose-multiplatform/build.gradle.kts create mode 100644 maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt create mode 100644 maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt create mode 100644 maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts new file mode 100644 index 000000000..9eb0110da --- /dev/null +++ b/maps-compose-multiplatform/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * 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 { + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.compose.compiler) +} + +kotlin { + androidLibrary { + namespace = "com.google.maps.android.compose.multiplatform" + compileSdk = libs.versions.androidCompileSdk.get().toInt() + minSdk = libs.versions.androidMinSdk.get().toInt() + } + + // Enable iOS targets + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain { + dependencies { + implementation("org.jetbrains.compose.runtime:runtime:1.7.3") + implementation("org.jetbrains.compose.foundation:foundation:1.7.3") + implementation("org.jetbrains.compose.ui:ui:1.7.3") + } + } + androidMain { + dependencies { + // Link the existing maps-compose module locally + api(project(":maps-compose")) + } + } + iosMain { + dependencies { + // Uses native MapKit via platform libraries + } + } + } +} diff --git a/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt b/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt new file mode 100644 index 000000000..d2696474f --- /dev/null +++ b/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt @@ -0,0 +1,40 @@ +/* + * 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.ui.Modifier +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.rememberCameraPositionState + +@Composable +public actual fun GoogleMap( + modifier: Modifier, + latitude: Double, + longitude: Double, + zoom: Float +) { + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(LatLng(latitude, longitude), zoom) + } + + com.google.maps.android.compose.GoogleMap( + modifier = modifier, + cameraPositionState = cameraPositionState + ) +} 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..40043bcb9 --- /dev/null +++ b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt @@ -0,0 +1,32 @@ +/* + * 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.ui.Modifier + +/** + * A multiplatform Map Composable that renders Google Maps on Android + * and native Apple MapKit Map (MKMapView) on iOS. + */ +@Composable +public expect fun GoogleMap( + modifier: Modifier = Modifier, + latitude: Double, + longitude: Double, + zoom: Float = 10f +) diff --git a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt new file mode 100644 index 000000000..5b5787717 --- /dev/null +++ b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt @@ -0,0 +1,51 @@ +/* + * 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.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitView +import kotlinx.cinterop.ExperimentalForeignApi +import kotlin.math.pow +import platform.CoreLocation.CLLocationCoordinate2DMake +import platform.MapKit.MKCoordinateRegionMake +import platform.MapKit.MKCoordinateSpanMake +import platform.MapKit.MKMapView + +@OptIn(ExperimentalForeignApi::class) +@Composable +public actual fun GoogleMap( + modifier: Modifier, + latitude: Double, + longitude: Double, + zoom: Float +) { + UIKitView( + factory = { + MKMapView() + }, + modifier = modifier, + update = { mapView -> + val center = CLLocationCoordinate2DMake(latitude, longitude) + // Approximate span calculation based on zoom level + val spanDelta = 360.0 / 2.0.pow(zoom.toDouble()) + val span = MKCoordinateSpanMake(spanDelta, spanDelta) + val region = MKCoordinateRegionMake(center, span) + mapView.setRegion(region, animated = true) + } + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e0b1768c2..e6c77516a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,4 +42,5 @@ include(":maps-app") include(":maps-compose") include(":maps-compose-widgets") include(":maps-compose-utils") +include(":maps-compose-multiplatform") include(":docs") \ No newline at end of file From 36c1c1b85353a2cd2a7d232f0111c526f1bd46b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 4 Jun 2026 09:18:33 +0200 Subject: [PATCH 02/14] feat: migrate iOS multiplatform map implementation to native Google Maps iOS SDK via CocoaPods --- maps-compose-multiplatform/build.gradle.kts | 11 ++++++++++ .../compose/multiplatform/GoogleMap.kt | 20 ++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index 9eb0110da..c721059eb 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -16,6 +16,7 @@ plugins { kotlin("multiplatform") + kotlin("native.cocoapods") id("com.android.kotlin.multiplatform.library") alias(libs.plugins.compose.compiler) } @@ -32,6 +33,16 @@ kotlin { iosArm64() iosSimulatorArm64() + cocoapods { + summary = "Multiplatform Google Maps wrapper" + homepage = "https://github.com/googlemaps/android-maps-compose" + version = "1.0" + ios.deploymentTarget = "14.0" + pod("GoogleMaps") { + version = "9.0.0" + } + } + sourceSets { commonMain { dependencies { diff --git a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt index 5b5787717..1e8e22540 100644 --- a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt +++ b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt @@ -20,11 +20,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.UIKitView import kotlinx.cinterop.ExperimentalForeignApi -import kotlin.math.pow -import platform.CoreLocation.CLLocationCoordinate2DMake -import platform.MapKit.MKCoordinateRegionMake -import platform.MapKit.MKCoordinateSpanMake -import platform.MapKit.MKMapView +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import cocoapods.GoogleMaps.GMSMapView +import cocoapods.GoogleMaps.GMSCameraPosition @OptIn(ExperimentalForeignApi::class) @Composable @@ -36,16 +35,13 @@ public actual fun GoogleMap( ) { UIKitView( factory = { - MKMapView() + val camera = GMSCameraPosition.cameraWithLatitude(latitude, longitude, zoom) + GMSMapView.mapWithFrame(CGRectZero.readValue(), camera = camera) }, modifier = modifier, update = { mapView -> - val center = CLLocationCoordinate2DMake(latitude, longitude) - // Approximate span calculation based on zoom level - val spanDelta = 360.0 / 2.0.pow(zoom.toDouble()) - val span = MKCoordinateSpanMake(spanDelta, spanDelta) - val region = MKCoordinateRegionMake(center, span) - mapView.setRegion(region, animated = true) + val camera = GMSCameraPosition.cameraWithLatitude(latitude, longitude, zoom) + mapView.animateToCameraPosition(camera) } ) } From 73f6bc4a557f44ea08d59793b21fd797d3fde2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 5 Jun 2026 17:35:55 +0200 Subject: [PATCH 03/14] feat: add interactive maps demos to iOS app & upgrade Google Maps SDK to 10.14.0 --- .gitignore | 1 + iosApp/.gitignore | 19 + iosApp/Podfile | 11 + iosApp/create_project.rb | 71 +++ iosApp/iosApp.xcodeproj/project.pbxproj | 418 ++++++++++++++++++ iosApp/iosApp/AppDelegate.swift | 25 ++ iosApp/iosApp/Info.plist | 34 ++ iosApp/iosApp/SampleListViewController.swift | 223 ++++++++++ maps-app/build.gradle.kts | 1 + maps-app/src/main/AndroidManifest.xml | 3 + .../com/google/maps/android/compose/Demo.kt | 5 + .../maps/android/compose/KmpMapActivity.kt | 49 ++ maps-app/src/main/res/values/strings.xml | 3 + maps-compose-multiplatform/build.gradle.kts | 23 +- .../maps_compose_multiplatform.podspec | 46 ++ .../compose/multiplatform/GoogleMap.kt | 46 +- .../compose/multiplatform/GoogleMap.kt | 26 +- .../compose/multiplatform/GoogleMap.kt | 120 ++++- 18 files changed, 1112 insertions(+), 12 deletions(-) create mode 100644 iosApp/.gitignore create mode 100644 iosApp/Podfile create mode 100644 iosApp/create_project.rb create mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 iosApp/iosApp/AppDelegate.swift create mode 100644 iosApp/iosApp/Info.plist create mode 100644 iosApp/iosApp/SampleListViewController.swift create mode 100644 maps-app/src/main/java/com/google/maps/android/compose/KmpMapActivity.kt create mode 100644 maps-compose-multiplatform/maps_compose_multiplatform.podspec 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/iosApp/.gitignore b/iosApp/.gitignore new file mode 100644 index 000000000..5d5a012c8 --- /dev/null +++ b/iosApp/.gitignore @@ -0,0 +1,19 @@ +# CocoaPods +Pods/ +Podfile.lock + +# Xcode +build/ +DerivedData/ +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +xcuserdata/ +*.xcworkspace +!default.xcworkspace +*.xcuserstate + +# Secrets +iosApp/DeveloperSecrets.swift + diff --git a/iosApp/Podfile b/iosApp/Podfile new file mode 100644 index 000000000..058583d95 --- /dev/null +++ b/iosApp/Podfile @@ -0,0 +1,11 @@ +platform :ios, '16.0' + +# Ignore warnings from CocoaPods dependencies +inhibit_all_warnings! + +target 'iosApp' do + use_frameworks! + + # Reference our local KMP module via its podspec path + pod 'maps_compose_multiplatform', :path => '../maps-compose-multiplatform' +end diff --git a/iosApp/create_project.rb b/iosApp/create_project.rb new file mode 100644 index 000000000..f256eae30 --- /dev/null +++ b/iosApp/create_project.rb @@ -0,0 +1,71 @@ +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') +sample_list_ref = group.new_file('SampleListViewController.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' +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.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 "^MAPS_API_KEY=" "$SECRETS_PATH" | cut -d'=' -f2 | tr -d '"' | tr -d "'") + 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(sample_list_ref) +source_build_phase.add_file_reference(secrets_ref) + +project.save +puts "Xcode project created successfully!" + diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..e5873b99c --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,418 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 09D299F7F5064B15DFF8647A /* SampleListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C845D21837B44B6FED85F1 /* SampleListViewController.swift */; }; + 1937ECD4C944A623885CB9C3 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA2BC2B1BE1E2F248AA9D65E /* Pods_iosApp.framework */; }; + 7FE65D91E154BA74FB35FBAC /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F1DCC9BB7B6BE074CB3D1D4F /* Foundation.framework */; }; + 81C277EB5057E02FF8B0CB5C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5293DC72FB4612B21EA96465 /* AppDelegate.swift */; }; + E997B8A8A2AFAD13A788A753 /* DeveloperSecrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EAB5F727C7CE0015606E231 /* DeveloperSecrets.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 18EAA545799221C4749B0BE6 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3EAB5F727C7CE0015606E231 /* DeveloperSecrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeveloperSecrets.swift; sourceTree = ""; }; + 5293DC72FB4612B21EA96465 /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AFAE64BF1EC03DC7EE9905AF /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; + B78650FF6C736F3A8024670F /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; + E3C845D21837B44B6FED85F1 /* SampleListViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleListViewController.swift; sourceTree = ""; }; + EA2BC2B1BE1E2F248AA9D65E /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F1DCC9BB7B6BE074CB3D1D4F /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + F37E4987EF58C0103DBE5753 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + ACD54AC92AF33CBA9038EF28 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7FE65D91E154BA74FB35FBAC /* Foundation.framework in Frameworks */, + 1937ECD4C944A623885CB9C3 /* Pods_iosApp.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 160662DC4B33BBB495406E79 /* Products */ = { + isa = PBXGroup; + children = ( + F37E4987EF58C0103DBE5753 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; + 761B8220F805A3F9823B2474 /* iOS */ = { + isa = PBXGroup; + children = ( + F1DCC9BB7B6BE074CB3D1D4F /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + 9A8CB19F0A8D45C35146683E = { + isa = PBXGroup; + children = ( + 160662DC4B33BBB495406E79 /* Products */, + AEC84CDA2E901AD18F65E5CF /* Frameworks */, + A8B082EA0D34BCDB9BC6A878 /* iosApp */, + B769C4D1E3A5E7DD227FDD0F /* Pods */, + ); + sourceTree = ""; + }; + A8B082EA0D34BCDB9BC6A878 /* iosApp */ = { + isa = PBXGroup; + children = ( + 5293DC72FB4612B21EA96465 /* AppDelegate.swift */, + E3C845D21837B44B6FED85F1 /* SampleListViewController.swift */, + 3EAB5F727C7CE0015606E231 /* DeveloperSecrets.swift */, + 18EAA545799221C4749B0BE6 /* Info.plist */, + ); + name = iosApp; + path = iosApp; + sourceTree = ""; + }; + AEC84CDA2E901AD18F65E5CF /* Frameworks */ = { + isa = PBXGroup; + children = ( + 761B8220F805A3F9823B2474 /* iOS */, + EA2BC2B1BE1E2F248AA9D65E /* Pods_iosApp.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + B769C4D1E3A5E7DD227FDD0F /* Pods */ = { + isa = PBXGroup; + children = ( + B78650FF6C736F3A8024670F /* Pods-iosApp.release.xcconfig */, + AFAE64BF1EC03DC7EE9905AF /* Pods-iosApp.debug.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5B40C8C8EA63318DDB4B739B /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0375DD885B6888B89AAE9BF9 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + A1495132AEB0B97D4FC21766 /* [CP] Check Pods Manifest.lock */, + 587CFD89D0C8586590AC06DD /* Populate Secrets */, + 854A57832A18FB33C8B65435 /* Sources */, + ACD54AC92AF33CBA9038EF28 /* Frameworks */, + 9FD86DF237A6365F05565BE6 /* Resources */, + 10D8D4B430DF1AE053CC752F /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + productName = iosApp; + productReference = F37E4987EF58C0103DBE5753 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E8320DC81573CAF4F46DC0D7 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = 12FCA89330D4CE38309E9B81 /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 9A8CB19F0A8D45C35146683E; + minimizedProjectReferenceProxies = 0; + preferredProjectObjectVersion = 77; + productRefGroup = 160662DC4B33BBB495406E79 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5B40C8C8EA63318DDB4B739B /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 9FD86DF237A6365F05565BE6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 10D8D4B430DF1AE053CC752F /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/GoogleMaps/GoogleMapsResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleMapsResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 587CFD89D0C8586590AC06DD /* Populate Secrets */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Populate Secrets"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "SECRETS_PATH=\"${PROJECT_DIR}/../secrets.properties\"\nOUTPUT_FILE=\"${SRCROOT}/iosApp/DeveloperSecrets.swift\"\n\nAPI_KEY=\"YOUR_API_KEY\"\n\nif [ -f \"$SECRETS_PATH\" ]; then\n EXTRACTED_KEY=$(grep -E \"^MAPS_API_KEY=\" \"$SECRETS_PATH\" | cut -d'=' -f2 | tr -d '\"' | tr -d \"'\")\n if [ ! -z \"$EXTRACTED_KEY\" ]; then\n API_KEY=\"$EXTRACTED_KEY\"\n fi\nfi\n\ncat < \"$OUTPUT_FILE\"\n// Generated file. Do not commit or modify.\nstruct DeveloperSecrets {\n static let mapsApiKey = \"$API_KEY\"\n}\nEOF\n"; + }; + A1495132AEB0B97D4FC21766 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 854A57832A18FB33C8B65435 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81C277EB5057E02FF8B0CB5C /* AppDelegate.swift in Sources */, + 09D299F7F5064B15DFF8647A /* SampleListViewController.swift in Sources */, + E997B8A8A2AFAD13A788A753 /* DeveloperSecrets.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 368E878A47CC5CC47D624E79 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 79144F741B9018EB7ABCBD73 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AFAE64BF1EC03DC7EE9905AF /* Pods-iosApp.debug.xcconfig */; + buildSettings = { + AD_HOC_CODE_SIGNING_ALLOWED = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + INFOPLIST_FILE = iosApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.google.maps.android.compose.iosApp; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A18BD2CB84E1A0278F561EB6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + AF9933E09D62868BBCC7C26A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B78650FF6C736F3A8024670F /* Pods-iosApp.release.xcconfig */; + buildSettings = { + AD_HOC_CODE_SIGNING_ALLOWED = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + INFOPLIST_FILE = iosApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.google.maps.android.compose.iosApp; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0375DD885B6888B89AAE9BF9 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF9933E09D62868BBCC7C26A /* Release */, + 79144F741B9018EB7ABCBD73 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 12FCA89330D4CE38309E9B81 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 368E878A47CC5CC47D624E79 /* Debug */, + A18BD2CB84E1A0278F561EB6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = E8320DC81573CAF4F46DC0D7 /* Project object */; +} diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift new file mode 100644 index 000000000..d206fe8fb --- /dev/null +++ b/iosApp/iosApp/AppDelegate.swift @@ -0,0 +1,25 @@ +import UIKit +import GoogleMaps +import maps_compose_multiplatform + +@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) + let sampleListVC = SampleListViewController() + let navController = UINavigationController(rootViewController: sampleListVC) + + window?.rootViewController = navController + window?.makeKeyAndVisible() + + return true + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 000000000..37d0d4e6c --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchScreen + + CADisableMinimumFrameDurationOnPhone + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/iosApp/iosApp/SampleListViewController.swift b/iosApp/iosApp/SampleListViewController.swift new file mode 100644 index 000000000..cc7a53a9c --- /dev/null +++ b/iosApp/iosApp/SampleListViewController.swift @@ -0,0 +1,223 @@ +import UIKit +import GoogleMaps +import maps_compose_multiplatform + +class SampleListViewController: UITableViewController { + + struct Sample { + let title: String + let description: String + let latitude: Double + let longitude: Double + let zoom: Float + let mapType: MapType + let myLocationEnabled: Bool + let scrollGesturesEnabled: Bool + let zoomGesturesEnabled: Bool + let markers: [MapMarker] + } + + struct Section { + let title: String + let samples: [Sample] + } + + private var sections: [Section] = [] + + override func viewDidLoad() { + super.viewDidLoad() + self.title = "KMP Maps Demos" + + setupSections() + self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + private func setupSections() { + sections = [ + Section( + title: "Map Types", + samples: [ + Sample( + title: "Normal Map", + description: "Standard road map of San Francisco", + latitude: 37.7749, + longitude: -122.4194, + zoom: 12.0, + mapType: .normal, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [] + ), + Sample( + title: "Satellite Map", + description: "Satellite imagery of the Grand Canyon", + latitude: 36.0544, + longitude: -112.1401, + zoom: 10.0, + mapType: .satellite, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [] + ), + Sample( + title: "Hybrid Map", + description: "Satellite with road names in New York City", + latitude: 40.7128, + longitude: -74.0060, + zoom: 11.0, + mapType: .hybrid, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [] + ), + Sample( + title: "Terrain Map", + description: "Topographic map of Mount Everest", + latitude: 27.9881, + longitude: 86.9250, + zoom: 10.0, + mapType: .terrain, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [] + ) + ] + ), + Section( + title: "Markers & Pins", + samples: [ + Sample( + title: "Single Marker", + description: "Marker at Golden Gate Bridge", + latitude: 37.8199, + longitude: -122.4783, + zoom: 13.0, + mapType: .normal, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [ + MapMarker(latitude: 37.8199, longitude: -122.4783, title: "Golden Gate Bridge", snippet: "San Francisco, CA") + ] + ), + Sample( + title: "Multiple Markers", + description: "Demos with Big Ben, Tower Bridge, London Eye", + latitude: 51.5033, + longitude: -0.1195, + zoom: 13.0, + mapType: .normal, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [ + MapMarker(latitude: 51.5007, longitude: -0.1246, title: "Big Ben", snippet: "Historic Clock Tower"), + MapMarker(latitude: 51.5055, longitude: -0.0754, title: "Tower Bridge", snippet: "Famous suspension bridge"), + MapMarker(latitude: 51.5033, longitude: -0.1195, title: "London Eye", snippet: "Giant Ferris Wheel") + ] + ), + Sample( + title: "Custom Snippet Marker", + description: "Tokyo Center with descriptive marker information", + latitude: 35.6762, + longitude: 139.6503, + zoom: 11.0, + mapType: .normal, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [ + MapMarker(latitude: 35.6762, longitude: 139.6503, title: "Tokyo City", snippet: "Population: 14 million people") + ] + ) + ] + ), + Section( + title: "Map Gestures & Controls", + samples: [ + Sample( + title: "Gestures Enabled (Default)", + description: "Fully interactive map with zoom and scroll support", + latitude: 48.8566, + longitude: 2.3522, + zoom: 12.0, + mapType: .normal, + myLocationEnabled: false, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [] + ), + Sample( + title: "Gestures Disabled", + description: "Static map centered on Rome, Italy (Scroll & zoom locked)", + latitude: 41.9028, + longitude: 12.4964, + zoom: 12.0, + mapType: .normal, + myLocationEnabled: false, + scrollGesturesEnabled: false, + zoomGesturesEnabled: false, + markers: [] + ), + Sample( + title: "Show My Location Button", + description: "Request location services & displays Location Button", + latitude: 48.8566, + longitude: 2.3522, + zoom: 12.0, + mapType: .normal, + myLocationEnabled: true, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + markers: [] + ) + ] + ) + ] + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].samples.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return sections[section].title + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "Cell") + let sample = sections[indexPath.section].samples[indexPath.row] + cell.textLabel?.text = sample.title + cell.detailTextLabel?.text = sample.description + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let sample = sections[indexPath.section].samples[indexPath.row] + + let mapViewController = GoogleMapKt.GoogleMapViewController( + latitude: sample.latitude, + longitude: sample.longitude, + zoom: sample.zoom, + mapType: sample.mapType, + myLocationEnabled: sample.myLocationEnabled, + scrollGesturesEnabled: sample.scrollGesturesEnabled, + zoomGesturesEnabled: sample.zoomGesturesEnabled, + markers: sample.markers + ) + + mapViewController.title = sample.title + self.navigationController?.pushViewController(mapViewController, animated: true) + } +} diff --git a/maps-app/build.gradle.kts b/maps-app/build.gradle.kts index a2ffb6567..a14c1d360 100644 --- a/maps-app/build.gradle.kts +++ b/maps-app/build.gradle.kts @@ -134,6 +134,7 @@ dependencies { implementation(project(":maps-compose")) implementation(project(":maps-compose-widgets")) implementation(project(":maps-compose-utils")) + implementation(project(":maps-compose-multiplatform")) } secrets { diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index d95e8c0ae..d94352990 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -118,6 +118,9 @@ + 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..37c616e65 --- /dev/null +++ b/maps-app/src/main/java/com/google/maps/android/compose/KmpMapActivity.kt @@ -0,0 +1,49 @@ +/* + * 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 androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import com.google.maps.android.compose.multiplatform.GoogleMap +import com.google.maps.android.compose.multiplatform.MapMarker + +class KmpMapActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + // Renders the multiplatform Map Composable + GoogleMap( + modifier = Modifier.fillMaxSize(), + latitude = 37.7749, // San Francisco + longitude = -122.4194, + zoom = 12f, + markers = listOf( + MapMarker( + latitude = 37.7749, + longitude = -122.4194, + title = "San Francisco", + snippet = "Welcome to SF!" + ) + ) + ) + } + } +} + diff --git a/maps-app/src/main/res/values/strings.xml b/maps-app/src/main/res/values/strings.xml index e0065b85f..b3e606257 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. + KMP Multiplatform Map + A map showcasing the KMP GoogleMap wrapper. + Map Types Map Features diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index c721059eb..0a24de589 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -37,9 +37,28 @@ kotlin { summary = "Multiplatform Google Maps wrapper" homepage = "https://github.com/googlemaps/android-maps-compose" version = "1.0" - ios.deploymentTarget = "14.0" + ios.deploymentTarget = "16.0" pod("GoogleMaps") { - version = "9.0.0" + 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") } } diff --git a/maps-compose-multiplatform/maps_compose_multiplatform.podspec b/maps-compose-multiplatform/maps_compose_multiplatform.podspec new file mode 100644 index 000000000..86cfb6f6c --- /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 = '1.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 = '15.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/GoogleMap.kt b/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt index d2696474f..564605539 100644 --- a/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt +++ b/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt @@ -17,6 +17,7 @@ package com.google.maps.android.compose.multiplatform import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng @@ -27,14 +28,53 @@ public actual fun GoogleMap( modifier: Modifier, latitude: Double, longitude: Double, - zoom: Float + zoom: Float, + mapType: MapType, + myLocationEnabled: Boolean, + scrollGesturesEnabled: Boolean, + zoomGesturesEnabled: Boolean, + markers: List ) { val cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(LatLng(latitude, longitude), zoom) } + val mapProperties = remember(mapType, myLocationEnabled) { + com.google.maps.android.compose.MapProperties( + mapType = when (mapType) { + MapType.NONE -> com.google.maps.android.compose.MapType.NONE + MapType.NORMAL -> com.google.maps.android.compose.MapType.NORMAL + MapType.SATELLITE -> com.google.maps.android.compose.MapType.SATELLITE + MapType.TERRAIN -> com.google.maps.android.compose.MapType.TERRAIN + MapType.HYBRID -> com.google.maps.android.compose.MapType.HYBRID + }, + isMyLocationEnabled = myLocationEnabled + ) + } + + val mapUiSettings = remember(scrollGesturesEnabled, zoomGesturesEnabled) { + com.google.maps.android.compose.MapUiSettings( + scrollGesturesEnabled = scrollGesturesEnabled, + zoomGesturesEnabled = zoomGesturesEnabled + ) + } + com.google.maps.android.compose.GoogleMap( modifier = modifier, - cameraPositionState = cameraPositionState - ) + cameraPositionState = cameraPositionState, + properties = mapProperties, + uiSettings = mapUiSettings + ) { + markers.forEach { markerData -> + val markerState = com.google.maps.android.compose.rememberMarkerState( + position = LatLng(markerData.latitude, markerData.longitude) + ) + com.google.maps.android.compose.Marker( + state = markerState, + title = markerData.title, + snippet = markerData.snippet + ) + } + } } + 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 index 40043bcb9..6be844a77 100644 --- 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 @@ -19,14 +19,34 @@ package com.google.maps.android.compose.multiplatform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +public enum class MapType { + NONE, + NORMAL, + SATELLITE, + TERRAIN, + HYBRID +} + +public data class MapMarker( + val latitude: Double, + val longitude: Double, + val title: String? = null, + val snippet: String? = null +) + /** - * A multiplatform Map Composable that renders Google Maps on Android - * and native Apple MapKit Map (MKMapView) on iOS. + * A multiplatform Map Composable that renders Google Maps on Android and iOS. */ @Composable public expect fun GoogleMap( modifier: Modifier = Modifier, latitude: Double, longitude: Double, - zoom: Float = 10f + zoom: Float = 10f, + mapType: MapType = MapType.NORMAL, + myLocationEnabled: Boolean = false, + scrollGesturesEnabled: Boolean = true, + zoomGesturesEnabled: Boolean = true, + markers: List = emptyList() ) + diff --git a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt index 1e8e22540..72f7365e3 100644 --- a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt +++ b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt @@ -22,16 +22,28 @@ import androidx.compose.ui.viewinterop.UIKitView import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectZero -import cocoapods.GoogleMaps.GMSMapView -import cocoapods.GoogleMaps.GMSCameraPosition +import cocoapods.GoogleMaps.* -@OptIn(ExperimentalForeignApi::class) +import platform.UIKit.UIViewController +import androidx.compose.ui.window.ComposeUIViewController +import androidx.compose.foundation.layout.fillMaxSize + +import androidx.compose.ui.viewinterop.UIKitInteropProperties +import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode +import platform.CoreLocation.CLLocationCoordinate2DMake + +@OptIn(ExperimentalForeignApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) @Composable public actual fun GoogleMap( modifier: Modifier, latitude: Double, longitude: Double, - zoom: Float + zoom: Float, + mapType: MapType, + myLocationEnabled: Boolean, + scrollGesturesEnabled: Boolean, + zoomGesturesEnabled: Boolean, + markers: List ) { UIKitView( factory = { @@ -39,9 +51,109 @@ public actual fun GoogleMap( GMSMapView.mapWithFrame(CGRectZero.readValue(), camera = camera) }, modifier = modifier, + properties = UIKitInteropProperties( + interactionMode = UIKitInteropInteractionMode.NonCooperative + ), update = { mapView -> val camera = GMSCameraPosition.cameraWithLatitude(latitude, longitude, zoom) mapView.animateToCameraPosition(camera) + + // Update map type + mapView.mapType = when (mapType) { + MapType.NONE -> kGMSTypeNone + MapType.NORMAL -> kGMSTypeNormal + MapType.SATELLITE -> kGMSTypeSatellite + MapType.TERRAIN -> kGMSTypeTerrain + MapType.HYBRID -> kGMSTypeHybrid + } + + // Update my location + mapView.myLocationEnabled = myLocationEnabled + mapView.settings.myLocationButton = myLocationEnabled + + // Update gestures + mapView.settings.scrollGestures = scrollGesturesEnabled + mapView.settings.zoomGestures = zoomGesturesEnabled + + // Clear old and add new markers + mapView.clear() + markers.forEach { markerData -> + val marker = GMSMarker() + marker.position = CLLocationCoordinate2DMake(markerData.latitude, markerData.longitude) + marker.title = markerData.title + marker.snippet = markerData.snippet + marker.map = mapView + } } ) } + +public fun GoogleMapViewController( + latitude: Double, + longitude: Double, + zoom: Float +): UIViewController { + return GoogleMapViewController( + latitude = latitude, + longitude = longitude, + zoom = zoom, + mapType = MapType.NORMAL, + myLocationEnabled = false, + scrollGesturesEnabled = true, + zoomGesturesEnabled = true, + markers = emptyList() + ) +} + +public fun GoogleMapViewController( + latitude: Double, + longitude: Double, + zoom: Float, + markerLatitude: Double?, + markerLongitude: Double?, + markerTitle: String? +): UIViewController { + val markersList = if (markerLatitude != null && markerLongitude != null) { + listOf(MapMarker(latitude = markerLatitude, longitude = markerLongitude, title = markerTitle)) + } else { + emptyList() + } + return GoogleMapViewController( + latitude = latitude, + longitude = longitude, + zoom = zoom, + mapType = MapType.NORMAL, + myLocationEnabled = false, + scrollGesturesEnabled = true, + zoomGesturesEnabled = true, + markers = markersList + ) +} + +public fun GoogleMapViewController( + latitude: Double, + longitude: Double, + zoom: Float, + mapType: MapType, + myLocationEnabled: Boolean, + scrollGesturesEnabled: Boolean, + zoomGesturesEnabled: Boolean, + markers: List +): UIViewController { + return ComposeUIViewController(configure = { + enforceStrictPlistSanityCheck = false + }) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + latitude = latitude, + longitude = longitude, + zoom = zoom, + mapType = mapType, + myLocationEnabled = myLocationEnabled, + scrollGesturesEnabled = scrollGesturesEnabled, + zoomGesturesEnabled = zoomGesturesEnabled, + markers = markers + ) + } +} + From f1737016d88197bf8bb5962ef413209dff04c861 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:19:48 -0600 Subject: [PATCH 04/14] fix(ios): declare DeveloperSecrets.swift output path & add BUILD_KMP.md --- BUILD_KMP.md | 83 +++++++++ iosApp/create_project.rb | 1 + iosApp/iosApp.xcodeproj/project.pbxproj | 237 ++++++++++++------------ 3 files changed, 203 insertions(+), 118 deletions(-) create mode 100644 BUILD_KMP.md diff --git a/BUILD_KMP.md b/BUILD_KMP.md new file mode 100644 index 000000000..a68effcad --- /dev/null +++ b/BUILD_KMP.md @@ -0,0 +1,83 @@ +# Kotlin Multiplatform (KMP) Maps Demo โ€” Build & Run Guide + +This guide details how to build and run the multiplatform Maps Compose library (`:maps-compose-multiplatform`), the Android demo app (`:maps-app`), and the iOS demo app (`iosApp`). + +--- + +## 1. Checkout the PR / Branch + +Fetch the remote branch and check it out (or add it as a Git worktree): + +```bash +git fetch origin feat/experimental-kmp-module +git checkout feat/experimental-kmp-module +``` + +*(Alternatively, to isolate your work in a worktree branch)*: +```bash +git worktree add -b feat/experimental-kmp-module feat-experimental-kmp-module origin/feat/experimental-kmp-module +``` + +--- + +## 2. Configure Local API Keys + +Create a file named `secrets.properties` in the **root** directory of the repository (if you don't already have one) and populate it with your Google Maps API keys: + +```properties +MAPS_API_KEY=YOUR_GOOGLE_MAPS_API_KEY +PLACES_API_KEY=YOUR_GOOGLE_PLACES_API_KEY +``` + +> [!IMPORTANT] +> Both `secrets.properties` and the generated iOS equivalent (`DeveloperSecrets.swift`) are ignored by Git to prevent accidentally leaking credentials. + +--- + +## 3. Building & Running for Android + +To compile and verify both the KMP library Android target and the Android sample application (`:maps-app`), run: + +```bash +./gradlew assembleDebug +``` + +--- + +## 4. Building & Running for iOS (Step-by-Step) + +If you are running the iOS project (`iosApp`) for the first time, follow these steps on a Mac with **Xcode** and **CocoaPods** installed: + +### Step 4.1: Generate the KMP Dummy Framework +Before CocoaPods can link the local `:maps-compose-multiplatform` podspec, the initial Kotlin framework must exist. From the repository root, execute: + +```bash +./gradlew :maps-compose-multiplatform:generateDummyFramework +``` + +### Step 4.2: Generate the Xcode Project +Navigate to the `iosApp` directory and run the project generation Ruby script (requires the `xcodeproj` gem): + +```bash +cd iosApp +ruby create_project.rb +``` + +> [!NOTE] +> **Build System Output Tracking (Xcode 16+)**: The `create_project.rb` script declares `$(SRCROOT)/iosApp/DeveloperSecrets.swift` as an output of the **Populate Secrets** script build phase. This prevents modern Xcode build systems from failing with `Build input file cannot be found: DeveloperSecrets.swift` during dependency graph analysis. + +### Step 4.3: Install Pod Dependencies +Install the required CocoaPods (`GoogleMaps` 10.14.0 and the local KMP library). If your local specs repository is out of date, use the `--repo-update` flag: + +```bash +pod install --repo-update +``` + +### Step 4.4: Open and Run in Xcode +1. Open the generated **Workspace** (do **not** open the `.xcodeproj` file directly): + ```bash + open iosApp.xcworkspace + ``` +2. In Xcode's top toolbar, select the active scheme dropdown and set it to **`iosApp`** (instead of `maps_compose_multiplatform`). +3. Choose an iOS Simulator (e.g., **iPhone 16** or **iPhone 17 Pro**) in the destination dropdown. +4. Click the **Play** button (or press **Cmd + R**) to compile, run the secrets population script, and launch the app! diff --git a/iosApp/create_project.rb b/iosApp/create_project.rb index f256eae30..cf90bfb1c 100644 --- a/iosApp/create_project.rb +++ b/iosApp/create_project.rb @@ -35,6 +35,7 @@ # 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" diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index e5873b99c..c8ac885ee 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,108 +7,108 @@ objects = { /* Begin PBXBuildFile section */ - 09D299F7F5064B15DFF8647A /* SampleListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C845D21837B44B6FED85F1 /* SampleListViewController.swift */; }; - 1937ECD4C944A623885CB9C3 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA2BC2B1BE1E2F248AA9D65E /* Pods_iosApp.framework */; }; - 7FE65D91E154BA74FB35FBAC /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F1DCC9BB7B6BE074CB3D1D4F /* Foundation.framework */; }; - 81C277EB5057E02FF8B0CB5C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5293DC72FB4612B21EA96465 /* AppDelegate.swift */; }; - E997B8A8A2AFAD13A788A753 /* DeveloperSecrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EAB5F727C7CE0015606E231 /* DeveloperSecrets.swift */; }; + 0D653230C7B4B77DED78F795 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B55B65E85F954E4BF49E7CA /* AppDelegate.swift */; }; + 5520C2B8920F1DE6E2146F81 /* SampleListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75DDC1C063EA32CE8824D07F /* SampleListViewController.swift */; }; + 7DE9F50AB020D738F9AC35AF /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7AB792F01E14CD3EEC1AF5B8 /* Pods_iosApp.framework */; }; + 9B39CDB0583E7EFC46398A00 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D66C3B8A1626E64624FFC75 /* Foundation.framework */; }; + C048757D5C4B50BC1AB38AE6 /* DeveloperSecrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C4DBA12C4C8AAC40F8EE45 /* DeveloperSecrets.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 18EAA545799221C4749B0BE6 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 3EAB5F727C7CE0015606E231 /* DeveloperSecrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeveloperSecrets.swift; sourceTree = ""; }; - 5293DC72FB4612B21EA96465 /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - AFAE64BF1EC03DC7EE9905AF /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; - B78650FF6C736F3A8024670F /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; - E3C845D21837B44B6FED85F1 /* SampleListViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleListViewController.swift; sourceTree = ""; }; - EA2BC2B1BE1E2F248AA9D65E /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F1DCC9BB7B6BE074CB3D1D4F /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - F37E4987EF58C0103DBE5753 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2E61E10ADF7CF13E32E000C0 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; + 4D66C3B8A1626E64624FFC75 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 51C4DBA12C4C8AAC40F8EE45 /* DeveloperSecrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeveloperSecrets.swift; sourceTree = ""; }; + 6B55B65E85F954E4BF49E7CA /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 711AFBB8CF132FD69AED5EAE /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 75DDC1C063EA32CE8824D07F /* SampleListViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleListViewController.swift; sourceTree = ""; }; + 7AB792F01E14CD3EEC1AF5B8 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7FA1089A5A50A88C1B0534CC /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; + B84637CCA3496DDB1F800E10 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - ACD54AC92AF33CBA9038EF28 /* Frameworks */ = { + 755A4F11212F18CBAE1A61A0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7FE65D91E154BA74FB35FBAC /* Foundation.framework in Frameworks */, - 1937ECD4C944A623885CB9C3 /* Pods_iosApp.framework in Frameworks */, + 9B39CDB0583E7EFC46398A00 /* Foundation.framework in Frameworks */, + 7DE9F50AB020D738F9AC35AF /* Pods_iosApp.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 160662DC4B33BBB495406E79 /* Products */ = { + 6572DE9AD86B0CC67C7E963E /* Frameworks */ = { isa = PBXGroup; children = ( - F37E4987EF58C0103DBE5753 /* iosApp.app */, + C469B336BFDB55F88E4144D1 /* iOS */, + 7AB792F01E14CD3EEC1AF5B8 /* Pods_iosApp.framework */, ); - name = Products; + name = Frameworks; sourceTree = ""; }; - 761B8220F805A3F9823B2474 /* iOS */ = { + 8F4470E4F5DD69A5F2052318 /* iosApp */ = { isa = PBXGroup; children = ( - F1DCC9BB7B6BE074CB3D1D4F /* Foundation.framework */, + 6B55B65E85F954E4BF49E7CA /* AppDelegate.swift */, + 75DDC1C063EA32CE8824D07F /* SampleListViewController.swift */, + 51C4DBA12C4C8AAC40F8EE45 /* DeveloperSecrets.swift */, + 711AFBB8CF132FD69AED5EAE /* Info.plist */, ); - name = iOS; + name = iosApp; + path = iosApp; sourceTree = ""; }; - 9A8CB19F0A8D45C35146683E = { + 9C595BA209CE46C978747670 = { isa = PBXGroup; children = ( - 160662DC4B33BBB495406E79 /* Products */, - AEC84CDA2E901AD18F65E5CF /* Frameworks */, - A8B082EA0D34BCDB9BC6A878 /* iosApp */, - B769C4D1E3A5E7DD227FDD0F /* Pods */, + F5812B1B51C667A99E3473DB /* Products */, + 6572DE9AD86B0CC67C7E963E /* Frameworks */, + 8F4470E4F5DD69A5F2052318 /* iosApp */, + C21DC034EFC3BBCE3CB62ECF /* Pods */, ); sourceTree = ""; }; - A8B082EA0D34BCDB9BC6A878 /* iosApp */ = { + C21DC034EFC3BBCE3CB62ECF /* Pods */ = { isa = PBXGroup; children = ( - 5293DC72FB4612B21EA96465 /* AppDelegate.swift */, - E3C845D21837B44B6FED85F1 /* SampleListViewController.swift */, - 3EAB5F727C7CE0015606E231 /* DeveloperSecrets.swift */, - 18EAA545799221C4749B0BE6 /* Info.plist */, + 7FA1089A5A50A88C1B0534CC /* Pods-iosApp.release.xcconfig */, + 2E61E10ADF7CF13E32E000C0 /* Pods-iosApp.debug.xcconfig */, ); - name = iosApp; - path = iosApp; + name = Pods; + path = Pods; sourceTree = ""; }; - AEC84CDA2E901AD18F65E5CF /* Frameworks */ = { + C469B336BFDB55F88E4144D1 /* iOS */ = { isa = PBXGroup; children = ( - 761B8220F805A3F9823B2474 /* iOS */, - EA2BC2B1BE1E2F248AA9D65E /* Pods_iosApp.framework */, + 4D66C3B8A1626E64624FFC75 /* Foundation.framework */, ); - name = Frameworks; + name = iOS; sourceTree = ""; }; - B769C4D1E3A5E7DD227FDD0F /* Pods */ = { + F5812B1B51C667A99E3473DB /* Products */ = { isa = PBXGroup; children = ( - B78650FF6C736F3A8024670F /* Pods-iosApp.release.xcconfig */, - AFAE64BF1EC03DC7EE9905AF /* Pods-iosApp.debug.xcconfig */, + B84637CCA3496DDB1F800E10 /* iosApp.app */, ); - name = Pods; - path = Pods; + name = Products; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 5B40C8C8EA63318DDB4B739B /* iosApp */ = { + F5CA17578FAB66DC77D30B26 /* iosApp */ = { isa = PBXNativeTarget; - buildConfigurationList = 0375DD885B6888B89AAE9BF9 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildConfigurationList = CF3EFFA659DA87ADF491844D /* Build configuration list for PBXNativeTarget "iosApp" */; buildPhases = ( - A1495132AEB0B97D4FC21766 /* [CP] Check Pods Manifest.lock */, - 587CFD89D0C8586590AC06DD /* Populate Secrets */, - 854A57832A18FB33C8B65435 /* Sources */, - ACD54AC92AF33CBA9038EF28 /* Frameworks */, - 9FD86DF237A6365F05565BE6 /* Resources */, - 10D8D4B430DF1AE053CC752F /* [CP] Copy Pods Resources */, + ABA2E729135D4636D5EBF68A /* [CP] Check Pods Manifest.lock */, + EA513F9C2ABA8D7F7B488510 /* Populate Secrets */, + C8810296C81A419006D89E5E /* Sources */, + 755A4F11212F18CBAE1A61A0 /* Frameworks */, + C6F77023AAF8823528A70ED2 /* Resources */, + D63BC68F2C76415CBAB80FEA /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -116,19 +116,19 @@ ); name = iosApp; productName = iosApp; - productReference = F37E4987EF58C0103DBE5753 /* iosApp.app */; + productReference = B84637CCA3496DDB1F800E10 /* iosApp.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - E8320DC81573CAF4F46DC0D7 /* Project object */ = { + 0BAC57553145A9D99D214F34 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; }; - buildConfigurationList = 12FCA89330D4CE38309E9B81 /* Build configuration list for PBXProject "iosApp" */; + buildConfigurationList = 2F8B9FD9291D2E34BAB2808B /* Build configuration list for PBXProject "iosApp" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; @@ -136,20 +136,20 @@ en, Base, ); - mainGroup = 9A8CB19F0A8D45C35146683E; + mainGroup = 9C595BA209CE46C978747670; minimizedProjectReferenceProxies = 0; preferredProjectObjectVersion = 77; - productRefGroup = 160662DC4B33BBB495406E79 /* Products */; + productRefGroup = F5812B1B51C667A99E3473DB /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 5B40C8C8EA63318DDB4B739B /* iosApp */, + F5CA17578FAB66DC77D30B26 /* iosApp */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 9FD86DF237A6365F05565BE6 /* Resources */ = { + C6F77023AAF8823528A70ED2 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -159,43 +159,47 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 10D8D4B430DF1AE053CC752F /* [CP] Copy Pods Resources */ = { + ABA2E729135D4636D5EBF68A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/GoogleMaps/GoogleMapsResources.bundle", + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "[CP] Copy Pods Resources"; outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleMapsResources.bundle", + "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 587CFD89D0C8586590AC06DD /* Populate Secrets */ = { + D63BC68F2C76415CBAB80FEA /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/GoogleMaps/GoogleMapsResources.bundle", ); - name = "Populate Secrets"; - outputFileListPaths = ( - ); + name = "[CP] Copy Pods Resources"; outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleMapsResources.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "SECRETS_PATH=\"${PROJECT_DIR}/../secrets.properties\"\nOUTPUT_FILE=\"${SRCROOT}/iosApp/DeveloperSecrets.swift\"\n\nAPI_KEY=\"YOUR_API_KEY\"\n\nif [ -f \"$SECRETS_PATH\" ]; then\n EXTRACTED_KEY=$(grep -E \"^MAPS_API_KEY=\" \"$SECRETS_PATH\" | cut -d'=' -f2 | tr -d '\"' | tr -d \"'\")\n if [ ! -z \"$EXTRACTED_KEY\" ]; then\n API_KEY=\"$EXTRACTED_KEY\"\n fi\nfi\n\ncat < \"$OUTPUT_FILE\"\n// Generated file. Do not commit or modify.\nstruct DeveloperSecrets {\n static let mapsApiKey = \"$API_KEY\"\n}\nEOF\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; + showEnvVarsInLog = 0; }; - A1495132AEB0B97D4FC21766 /* [CP] Check Pods Manifest.lock */ = { + EA513F9C2ABA8D7F7B488510 /* Populate Secrets */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -203,37 +207,34 @@ inputFileListPaths = ( ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", ); - name = "[CP] Check Pods Manifest.lock"; + name = "Populate Secrets"; outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", + "$(SRCROOT)/iosApp/DeveloperSecrets.swift", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "SECRETS_PATH=\"${PROJECT_DIR}/../secrets.properties\"\nOUTPUT_FILE=\"${SRCROOT}/iosApp/DeveloperSecrets.swift\"\n\nAPI_KEY=\"YOUR_API_KEY\"\n\nif [ -f \"$SECRETS_PATH\" ]; then\n EXTRACTED_KEY=$(grep -E \"^MAPS_API_KEY=\" \"$SECRETS_PATH\" | cut -d'=' -f2 | tr -d '\"' | tr -d \"'\")\n if [ ! -z \"$EXTRACTED_KEY\" ]; then\n API_KEY=\"$EXTRACTED_KEY\"\n fi\nfi\n\ncat < \"$OUTPUT_FILE\"\n// Generated file. Do not commit or modify.\nstruct DeveloperSecrets {\n static let mapsApiKey = \"$API_KEY\"\n}\nEOF\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 854A57832A18FB33C8B65435 /* Sources */ = { + C8810296C81A419006D89E5E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 81C277EB5057E02FF8B0CB5C /* AppDelegate.swift in Sources */, - 09D299F7F5064B15DFF8647A /* SampleListViewController.swift in Sources */, - E997B8A8A2AFAD13A788A753 /* DeveloperSecrets.swift in Sources */, + 0D653230C7B4B77DED78F795 /* AppDelegate.swift in Sources */, + 5520C2B8920F1DE6E2146F81 /* SampleListViewController.swift in Sources */, + C048757D5C4B50BC1AB38AE6 /* DeveloperSecrets.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ - 368E878A47CC5CC47D624E79 /* Debug */ = { + 30D83033DD758DCAE2B65F37 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -267,17 +268,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -285,19 +280,18 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; }; - name = Debug; + name = Release; }; - 79144F741B9018EB7ABCBD73 /* Debug */ = { + 79F5FD2A2E53F2F285DB566F /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AFAE64BF1EC03DC7EE9905AF /* Pods-iosApp.debug.xcconfig */; + baseConfigurationReference = 7FA1089A5A50A88C1B0534CC /* Pods-iosApp.release.xcconfig */; buildSettings = { AD_HOC_CODE_SIGNING_ALLOWED = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -312,10 +306,11 @@ SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; }; - name = Debug; + name = Release; }; - A18BD2CB84E1A0278F561EB6 /* Release */ = { + 911DF9EA0DD218A18147B490 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -349,11 +344,17 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; + DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -361,18 +362,19 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MTL_ENABLE_DEBUG_INFO = NO; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; - name = Release; + name = Debug; }; - AF9933E09D62868BBCC7C26A /* Release */ = { + BE70C656E6CD2E4A26E99D95 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B78650FF6C736F3A8024670F /* Pods-iosApp.release.xcconfig */; + baseConfigurationReference = 2E61E10ADF7CF13E32E000C0 /* Pods-iosApp.debug.xcconfig */; buildSettings = { AD_HOC_CODE_SIGNING_ALLOWED = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -387,32 +389,31 @@ SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; }; - name = Release; + name = Debug; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0375DD885B6888B89AAE9BF9 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + 2F8B9FD9291D2E34BAB2808B /* Build configuration list for PBXProject "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( - AF9933E09D62868BBCC7C26A /* Release */, - 79144F741B9018EB7ABCBD73 /* Debug */, + 911DF9EA0DD218A18147B490 /* Debug */, + 30D83033DD758DCAE2B65F37 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 12FCA89330D4CE38309E9B81 /* Build configuration list for PBXProject "iosApp" */ = { + CF3EFFA659DA87ADF491844D /* Build configuration list for PBXNativeTarget "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( - 368E878A47CC5CC47D624E79 /* Debug */, - A18BD2CB84E1A0278F561EB6 /* Release */, + 79F5FD2A2E53F2F285DB566F /* Release */, + BE70C656E6CD2E4A26E99D95 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; - rootObject = E8320DC81573CAF4F46DC0D7 /* Project object */; + rootObject = 0BAC57553145A9D99D214F34 /* Project object */; } From fbae135d0fc3c9b40f934511ef17623ee7eee839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:29:44 +0700 Subject: [PATCH 05/14] feat: consume multiplatform clustering from android-maps-utils KMP branch Adds common-code clustering to maps-compose-multiplatform via the experimental KMP modules of android-maps-utils (maps-model, clustering): clusterMarkers() runs NonHierarchicalDistanceBasedAlgorithm in commonMain and ClusteredGoogleMap renders the resulting clusters, so Android and iOS produce identical clusters from shared code. Consumption paths (see settings.gradle.kts): publishToMavenLocal from ../android-maps-utils plus -PuseMavenLocal=true (full build works, this is the default), or -PuseLocalMapsUtils=true for a composite build (platform compilations work; shared-metadata compilation is skipped by Kotlin's granular-metadata transform across included builds and fails). Note: linkPodDebugFrameworkIosArm64 fails on this branch with or without this change (Kotlin/Native cache failure on compose-ui 1.7.3 uikitarm64); simulator link and all target compilations pass. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- maps-compose-multiplatform/build.gradle.kts | 4 + .../compose/multiplatform/Clustering.kt | 106 ++++++++++++++++++ settings.gradle.kts | 24 ++++ 3 files changed, 134 insertions(+) create mode 100644 maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/Clustering.kt diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index 0a24de589..342706aa4 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -68,6 +68,10 @@ kotlin { implementation("org.jetbrains.compose.runtime:runtime:1.7.3") implementation("org.jetbrains.compose.foundation:foundation:1.7.3") implementation("org.jetbrains.compose.ui:ui:1.7.3") + // Multiplatform clustering from android-maps-utils; substituted with the + // local ../android-maps-utils composite build (see settings.gradle.kts). + api("com.google.maps.android:maps-model:5.2.0") + api("com.google.maps.android:clustering:5.2.0") } } androidMain { 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..2923541ae --- /dev/null +++ b/maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/Clustering.kt @@ -0,0 +1,106 @@ +/* + * 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.remember +import androidx.compose.ui.Modifier +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude + +/** + * Adapts a [MapMarker] to android-maps-utils' [ClusterItem] so it can be fed to the + * multiplatform clustering algorithms. + */ +private class MapMarkerClusterItem( + val marker: MapMarker, +) : ClusterItem { + override val position: LatLng = LatLng(marker.latitude, marker.longitude) + + override val title: String? = marker.title + + override val snippet: String? = marker.snippet + + override val zIndex: Float? = null +} + +/** + * Groups [markers] into clusters for the given [zoom] level using the multiplatform + * clustering algorithm from android-maps-utils. Runs entirely in common code, so Android + * and iOS produce identical clusters. + * + * Clusters of one item pass the original marker through; larger clusters are represented + * by a marker at the cluster position whose title carries the cluster size. + */ +public fun clusterMarkers( + markers: List, + zoom: Float, + maxDistanceBetweenClusteredItems: Int = 100, +): List { + val algorithm = NonHierarchicalDistanceBasedAlgorithm() + algorithm.maxDistanceBetweenClusteredItems = maxDistanceBetweenClusteredItems + algorithm.addItems(markers.map { MapMarkerClusterItem(it) }) + return algorithm.getClusters(zoom).map { cluster -> + val items = cluster.items + if (items.size == 1) { + items.first().marker + } else { + MapMarker( + latitude = cluster.position.latitude, + longitude = cluster.position.longitude, + title = "${items.size} items", + ) + } + } +} + +/** + * A [GoogleMap] that clusters [markers] with android-maps-utils' multiplatform clustering + * algorithm before rendering them. Clustering is recomputed when the markers or [zoom] + * change. + */ +@Composable +public fun ClusteredGoogleMap( + modifier: Modifier = Modifier, + latitude: Double, + longitude: Double, + zoom: Float = 10f, + mapType: MapType = MapType.NORMAL, + myLocationEnabled: Boolean = false, + scrollGesturesEnabled: Boolean = true, + zoomGesturesEnabled: Boolean = true, + markers: List = emptyList(), + maxDistanceBetweenClusteredItems: Int = 100, +) { + val clustered = remember(markers, zoom, maxDistanceBetweenClusteredItems) { + clusterMarkers(markers, zoom, maxDistanceBetweenClusteredItems) + } + GoogleMap( + modifier = modifier, + latitude = latitude, + longitude = longitude, + zoom = zoom, + mapType = mapType, + myLocationEnabled = myLocationEnabled, + scrollGesturesEnabled = scrollGesturesEnabled, + zoomGesturesEnabled = zoomGesturesEnabled, + markers = clustered, + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e6c77516a..c0f3d7de1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,30 @@ 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. +// +// Default consumption path: publish those modules to mavenLocal and enable the +// mavenLocal repository (see dependencyResolutionManagement above): +// (in ../android-maps-utils) ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal +// (here) ./gradlew -PuseMavenLocal=true +// +// Alternative for tight iteration: -PuseLocalMapsUtils=true substitutes the two +// coordinates with a composite build of ../android-maps-utils. Note that with the +// composite, platform compilations (android/iOS) work but the shared-metadata +// compilation (compileCommonMainKotlinMetadata, and thus assemble) fails: Kotlin's +// granular metadata transformation currently 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:clustering")).using(project(":clustering")) + substitute(module("com.google.maps.android:maps-model")).using(project(":maps-model")) + } + } +} + include(":maps-app") include(":maps-compose") include(":maps-compose-widgets") From 31d03700136030e01bb58c31efc3acc7578110a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:38:38 +0700 Subject: [PATCH 06/14] ci: publish KMP maps-utils modules to mavenLocal before building The maps-compose-multiplatform module now depends on com.google.maps.android:maps-model and :clustering from the feat/experimental-kmp-clustering branch of android-maps-utils, which are not published to any remote repository. Check out that branch and publish the two modules to the runner's mavenLocal in the lint, test and instrumentation workflows, and set USE_MAVEN_LOCAL so the compose build resolves them from there. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- .github/workflows/instrumentation-test.yml | 15 +++++++++++++++ .github/workflows/lint-report.yml | 15 +++++++++++++++ .github/workflows/test.yml | 15 +++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml index 34018e151..97471ff72 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -25,6 +25,8 @@ on: jobs: run-instrumentation-test: runs-on: macOS-latest-large # enables hardware acceleration in the virtual machine + env: + USE_MAVEN_LOCAL: "true" permissions: pull-requests: write timeout-minutes: 30 @@ -41,6 +43,19 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Checkout android-maps-utils (experimental KMP branch) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: googlemaps/android-maps-utils + ref: feat/experimental-kmp-clustering + path: maps-utils-kmp + + - name: Publish multiplatform maps-utils modules to mavenLocal + working-directory: maps-utils-kmp + run: | + printf 'MAPS_API_KEY=PLACEHOLDER\nPLACES_API_KEY=PLACEHOLDER\nMAP_ID=PLACEHOLDER\n' > secrets.properties + ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal + - name: Inject Maps API Key env: MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 099f6fe23..947bc449f 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -26,6 +26,8 @@ permissions: jobs: lint: runs-on: ubuntu-latest + env: + USE_MAVEN_LOCAL: "true" steps: - name: Checkout code @@ -40,6 +42,19 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Checkout android-maps-utils (experimental KMP branch) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: googlemaps/android-maps-utils + ref: feat/experimental-kmp-clustering + path: maps-utils-kmp + + - name: Publish multiplatform maps-utils modules to mavenLocal + working-directory: maps-utils-kmp + run: | + printf 'MAPS_API_KEY=PLACEHOLDER\nPLACES_API_KEY=PLACEHOLDER\nMAP_ID=PLACEHOLDER\n' > secrets.properties + ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal + - name: Run Android Lint run: ./gradlew lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd73fac65..bccd53952 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,8 @@ jobs: permissions: contents: read pull-requests: write + env: + USE_MAVEN_LOCAL: "true" steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it @@ -47,6 +49,19 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Checkout android-maps-utils (experimental KMP branch) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: googlemaps/android-maps-utils + ref: feat/experimental-kmp-clustering + path: maps-utils-kmp + + - name: Publish multiplatform maps-utils modules to mavenLocal + working-directory: maps-utils-kmp + run: | + printf 'MAPS_API_KEY=PLACEHOLDER\nPLACES_API_KEY=PLACEHOLDER\nMAP_ID=PLACEHOLDER\n' > secrets.properties + ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal + - name: Build modules run: ./gradlew build koverXmlReportDebug -x :maps-app:generateDebugScreenshotTestConfig -x :maps-app:generateReleaseScreenshotTestConfig --stacktrace From 7aed67a0a9099b883f18de13b5a782ad651ca5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:48:15 +0700 Subject: [PATCH 07/14] fix: consume KMP maps-utils under its public artifactIds com.google.maps.android:clustering duplicated the classes of the android-maps-utils-clustering AAR that maps-compose-utils pulls from Maven Central, failing maps-app's checkDebugDuplicateClasses. Depend on android-maps-utils-clustering / android-maps-utils-maps-model instead (matching the renamed KMP publications) so Gradle resolves both paths to one module; mavenLocal shadows the Central AAR with the API-compatible KMP artifact. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- maps-compose-multiplatform/build.gradle.kts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index 342706aa4..06520606c 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -68,10 +68,12 @@ kotlin { implementation("org.jetbrains.compose.runtime:runtime:1.7.3") implementation("org.jetbrains.compose.foundation:foundation:1.7.3") implementation("org.jetbrains.compose.ui:ui:1.7.3") - // Multiplatform clustering from android-maps-utils; substituted with the - // local ../android-maps-utils composite build (see settings.gradle.kts). - api("com.google.maps.android:maps-model:5.2.0") - api("com.google.maps.android:clustering:5.2.0") + // Multiplatform clustering from android-maps-utils (KMP branch); resolved from + // mavenLocal or the composite build (see settings.gradle.kts). Uses the repo's + // public artifactIds so it conflict-resolves as the same module as the AAR that + // maps-compose-utils pulls from Maven Central instead of duplicating its classes. + api("com.google.maps.android:android-maps-utils-maps-model:5.2.0") + api("com.google.maps.android:android-maps-utils-clustering:5.2.0") } } androidMain { From 94a8178b3ca2e0cb3208c12b80c912a2bd98a42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 2 Oct 2026 06:58:20 +0200 Subject: [PATCH 08/14] build: consume the maps-utils 6.1.0-SNAPSHOT instead of a mavenLocal build --- .github/workflows/instrumentation-test.yml | 30 ++++++++------------ .github/workflows/lint-report.yml | 15 ---------- .github/workflows/test.yml | 15 ---------- gradle/libs.versions.toml | 8 ++++++ maps-compose-multiplatform/build.gradle.kts | 20 ++++++------- settings.gradle.kts | 31 +++++++++++++-------- 6 files changed, 48 insertions(+), 71 deletions(-) diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml index 97471ff72..adef9c776 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -25,11 +25,9 @@ on: jobs: run-instrumentation-test: runs-on: macOS-latest-large # enables hardware acceleration in the virtual machine - env: - USE_MAVEN_LOCAL: "true" permissions: pull-requests: write - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -43,19 +41,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - - name: Checkout android-maps-utils (experimental KMP branch) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: googlemaps/android-maps-utils - ref: feat/experimental-kmp-clustering - path: maps-utils-kmp - - - name: Publish multiplatform maps-utils modules to mavenLocal - working-directory: maps-utils-kmp - run: | - printf 'MAPS_API_KEY=PLACEHOLDER\nPLACES_API_KEY=PLACEHOLDER\nMAP_ID=PLACEHOLDER\n' > secrets.properties - ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal - - name: Inject Maps API Key env: MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} @@ -73,7 +58,7 @@ jobs: arch: x86 disable-animations: true # The demo smoke tests run in their own workflow, see demo-smoke-test.yml. - script: ./gradlew createDebugCoverageReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=com.google.maps.android.compose.smoke + script: ./gradlew createDebugCoverageReport :maps-app:createLibraryCoverageReports --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=com.google.maps.android.compose.smoke # Stage the coverage reports flat, named after their module, and publish # them so the post-merge "Record coverage history" workflow can read them @@ -88,6 +73,12 @@ jobs: [ -e "$report" ] || continue cp "$report" "instrumentation-coverage/${report%%/*}.xml" done + # Library modules, reported from the coverage data of every emulator test run. This + # replaces maps-compose-widgets' own report, which only counted its own tests. + for report in maps-app/build/reports/library-coverage/*.xml; do + [ -e "$report" ] || continue + cp "$report" instrumentation-coverage/ + done ls -l instrumentation-coverage - name: Upload coverage reports @@ -103,7 +94,10 @@ jobs: uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0 with: paths: | - ${{ github.workspace }}/maps-app/build/reports/coverage/androidTest/debug/connected/report.xml + ${{ github.workspace }}/maps-app/build/reports/coverage/androidTest/debug/connected/report.xml, + ${{ github.workspace }}/maps-app/build/reports/library-coverage/maps-compose.xml, + ${{ github.workspace }}/maps-app/build/reports/library-coverage/maps-compose-utils.xml, + ${{ github.workspace }}/maps-app/build/reports/library-coverage/maps-compose-widgets.xml token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} min-coverage-overall: 26 min-coverage-changed-files: 60 diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 947bc449f..099f6fe23 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -26,8 +26,6 @@ permissions: jobs: lint: runs-on: ubuntu-latest - env: - USE_MAVEN_LOCAL: "true" steps: - name: Checkout code @@ -42,19 +40,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - - name: Checkout android-maps-utils (experimental KMP branch) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: googlemaps/android-maps-utils - ref: feat/experimental-kmp-clustering - path: maps-utils-kmp - - - name: Publish multiplatform maps-utils modules to mavenLocal - working-directory: maps-utils-kmp - run: | - printf 'MAPS_API_KEY=PLACEHOLDER\nPLACES_API_KEY=PLACEHOLDER\nMAP_ID=PLACEHOLDER\n' > secrets.properties - ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal - - name: Run Android Lint run: ./gradlew lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bccd53952..fd73fac65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,8 +32,6 @@ jobs: permissions: contents: read pull-requests: write - env: - USE_MAVEN_LOCAL: "true" steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it @@ -49,19 +47,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - - name: Checkout android-maps-utils (experimental KMP branch) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: googlemaps/android-maps-utils - ref: feat/experimental-kmp-clustering - path: maps-utils-kmp - - - name: Publish multiplatform maps-utils modules to mavenLocal - working-directory: maps-utils-kmp - run: | - printf 'MAPS_API_KEY=PLACEHOLDER\nPLACES_API_KEY=PLACEHOLDER\nMAP_ID=PLACEHOLDER\n' > secrets.properties - ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal - - name: Build modules run: ./gradlew build koverXmlReportDebug -x :maps-app:generateDebugScreenshotTestConfig -x :maps-app:generateReleaseScreenshotTestConfig --stacktrace diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94c1adcc0..95f1478e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,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" @@ -72,6 +75,11 @@ 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-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/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index 06520606c..95ac9c9e1 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -28,8 +28,7 @@ kotlin { minSdk = libs.versions.androidMinSdk.get().toInt() } - // Enable iOS targets - iosX64() + // Enable iOS targets. No iosX64 (Intel simulators): Compose Multiplatform dropped it in 1.11. iosArm64() iosSimulatorArm64() @@ -65,15 +64,14 @@ kotlin { sourceSets { commonMain { dependencies { - implementation("org.jetbrains.compose.runtime:runtime:1.7.3") - implementation("org.jetbrains.compose.foundation:foundation:1.7.3") - implementation("org.jetbrains.compose.ui:ui:1.7.3") - // Multiplatform clustering from android-maps-utils (KMP branch); resolved from - // mavenLocal or the composite build (see settings.gradle.kts). Uses the repo's - // public artifactIds so it conflict-resolves as the same module as the AAR that - // maps-compose-utils pulls from Maven Central instead of duplicating its classes. - api("com.google.maps.android:android-maps-utils-maps-model:5.2.0") - api("com.google.maps.android:android-maps-utils-clustering:5.2.0") + 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.clustering) } } androidMain { diff --git a/settings.gradle.kts b/settings.gradle.kts index c0f3d7de1..d74e64a7f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,13 @@ dependencyResolutionManagement { } 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 { @@ -40,24 +47,24 @@ 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. +// 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. // -// Default consumption path: publish those modules to mavenLocal and enable the -// mavenLocal repository (see dependencyResolutionManagement above): +// To try unpublished changes, publish them to mavenLocal and build with -PuseMavenLocal=true: // (in ../android-maps-utils) ./gradlew :maps-model:publishToMavenLocal :clustering:publishToMavenLocal -// (here) ./gradlew -PuseMavenLocal=true // -// Alternative for tight iteration: -PuseLocalMapsUtils=true substitutes the two -// coordinates with a composite build of ../android-maps-utils. Note that with the -// composite, platform compilations (android/iOS) work but the shared-metadata -// compilation (compileCommonMainKotlinMetadata, and thus assemble) fails: Kotlin's -// granular metadata transformation currently skips project dependencies substituted -// across included builds, so common code cannot be analyzed against them. +// -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:clustering")).using(project(":clustering")) - substitute(module("com.google.maps.android:maps-model")).using(project(":maps-model")) + 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")) } } } From 7c5cd5d9d59d6c4be26ca60d767fc8bef9d1352d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 2 Oct 2026 07:30:46 +0200 Subject: [PATCH 09/14] build: publish maps-compose-multiplatform with API checks, lint and iOS CI - KmpPublishingConventionPlugin: explicit API mode, iosArm64 and iosSimulatorArm64, lint, Kover and Maven Central publishing as maps-compose-multiplatform - Public API checks for the iOS klibs (Kotlin ABI validation) and the Android target (binary-compatibility-validator), in api/ - Publishing a release fails while it depends on a maps-utils snapshot - New macOS workflow builds the iOS targets and the iOS demo app - The publish workflow runs on macOS, which the iOS klibs need --- .github/workflows/multiplatform.yml | 70 ++++++++ .github/workflows/publish.yml | 13 +- build-logic/convention/build.gradle.kts | 5 + .../kotlin/KmpPublishingConventionPlugin.kt | 152 ++++++++++++++++++ .../src/main/kotlin/MapsComposePublishing.kt | 58 +++++++ .../main/kotlin/PublishingConventionPlugin.kt | 31 +--- gradle/libs.versions.toml | 2 + .../api/maps-compose-multiplatform.api | 40 +++++ .../api/maps-compose-multiplatform.klib.api | 53 ++++++ maps-compose-multiplatform/build.gradle.kts | 29 +++- 10 files changed, 409 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/multiplatform.yml create mode 100644 build-logic/convention/src/main/kotlin/KmpPublishingConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/MapsComposePublishing.kt create mode 100644 maps-compose-multiplatform/api/maps-compose-multiplatform.api create mode 100644 maps-compose-multiplatform/api/maps-compose-multiplatform.klib.api diff --git a/.github/workflows/multiplatform.yml b/.github/workflows/multiplatform.yml new file mode 100644 index 000000000..e35b287e4 --- /dev/null +++ b/.github/workflows/multiplatform.yml @@ -0,0 +1,70 @@ +# 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 + + # 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:generateDummyFramework) + ruby create_project.rb + pod install + xcodebuild build \ + -workspace iosApp.xcworkspace \ + -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + 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/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 95f1478e6..0c2aacbec 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" @@ -50,6 +51,7 @@ 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" } 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..601061231 --- /dev/null +++ b/maps-compose-multiplatform/api/maps-compose-multiplatform.api @@ -0,0 +1,40 @@ +public final class com/google/maps/android/compose/multiplatform/ClusteringKt { + public static final fun ClusteredGoogleMap (Landroidx/compose/ui/Modifier;DDFLcom/google/maps/android/compose/multiplatform/MapType;ZZZLjava/util/List;ILandroidx/compose/runtime/Composer;II)V + public static final fun clusterMarkers (Ljava/util/List;FI)Ljava/util/List; + public static synthetic fun clusterMarkers$default (Ljava/util/List;FIILjava/lang/Object;)Ljava/util/List; +} + +public final class com/google/maps/android/compose/multiplatform/GoogleMapKt { + public static final fun GoogleMap (Landroidx/compose/ui/Modifier;DDFLcom/google/maps/android/compose/multiplatform/MapType;ZZZLjava/util/List;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/google/maps/android/compose/multiplatform/MapMarker { + public static final field $stable I + public fun (DDLjava/lang/String;Ljava/lang/String;)V + public synthetic fun (DDLjava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()D + public final fun component2 ()D + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (DDLjava/lang/String;Ljava/lang/String;)Lcom/google/maps/android/compose/multiplatform/MapMarker; + public static synthetic fun copy$default (Lcom/google/maps/android/compose/multiplatform/MapMarker;DDLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/google/maps/android/compose/multiplatform/MapMarker; + public fun equals (Ljava/lang/Object;)Z + public final fun getLatitude ()D + public final fun getLongitude ()D + public final fun getSnippet ()Ljava/lang/String; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + 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; +} + 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..9d57ef8f5 --- /dev/null +++ b/maps-compose-multiplatform/api/maps-compose-multiplatform.klib.api @@ -0,0 +1,53 @@ +// 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/MapMarker { // com.google.maps.android.compose.multiplatform/MapMarker|null[0] + constructor (kotlin/Double, kotlin/Double, kotlin/String? = ..., kotlin/String? = ...) // com.google.maps.android.compose.multiplatform/MapMarker.|(kotlin.Double;kotlin.Double;kotlin.String?;kotlin.String?){}[0] + + final val latitude // com.google.maps.android.compose.multiplatform/MapMarker.latitude|{}latitude[0] + final fun (): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.latitude.|(){}[0] + final val longitude // com.google.maps.android.compose.multiplatform/MapMarker.longitude|{}longitude[0] + final fun (): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.longitude.|(){}[0] + final val snippet // com.google.maps.android.compose.multiplatform/MapMarker.snippet|{}snippet[0] + final fun (): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.snippet.|(){}[0] + final val title // com.google.maps.android.compose.multiplatform/MapMarker.title|{}title[0] + final fun (): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.title.|(){}[0] + + final fun component1(): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.component1|component1(){}[0] + final fun component2(): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.component2|component2(){}[0] + final fun component3(): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.component3|component3(){}[0] + final fun component4(): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.component4|component4(){}[0] + final fun copy(kotlin/Double = ..., kotlin/Double = ..., kotlin/String? = ..., kotlin/String? = ...): com.google.maps.android.compose.multiplatform/MapMarker // com.google.maps.android.compose.multiplatform/MapMarker.copy|copy(kotlin.Double;kotlin.Double;kotlin.String?;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapMarker.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.google.maps.android.compose.multiplatform/MapMarker.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.google.maps.android.compose.multiplatform/MapMarker.toString|toString(){}[0] +} + +final val com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapMarker$stableprop // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapMarker$stableprop|#static{}com_google_maps_android_compose_multiplatform_MapMarker$stableprop[0] + +final fun com.google.maps.android.compose.multiplatform/ClusteredGoogleMap(androidx.compose.ui/Modifier?, kotlin/Double, kotlin/Double, kotlin/Float, com.google.maps.android.compose.multiplatform/MapType?, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin.collections/List?, kotlin/Int, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/ClusteredGoogleMap|ClusteredGoogleMap(androidx.compose.ui.Modifier?;kotlin.Double;kotlin.Double;kotlin.Float;com.google.maps.android.compose.multiplatform.MapType?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.collections.List?;kotlin.Int;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/GoogleMap(androidx.compose.ui/Modifier?, kotlin/Double, kotlin/Double, kotlin/Float, com.google.maps.android.compose.multiplatform/MapType?, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin.collections/List?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/GoogleMap|GoogleMap(androidx.compose.ui.Modifier?;kotlin.Double;kotlin.Double;kotlin.Float;com.google.maps.android.compose.multiplatform.MapType?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.collections.List?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/GoogleMapViewController(kotlin/Double, kotlin/Double, kotlin/Float): platform.UIKit/UIViewController // com.google.maps.android.compose.multiplatform/GoogleMapViewController|GoogleMapViewController(kotlin.Double;kotlin.Double;kotlin.Float){}[0] +final fun com.google.maps.android.compose.multiplatform/GoogleMapViewController(kotlin/Double, kotlin/Double, kotlin/Float, com.google.maps.android.compose.multiplatform/MapType, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin.collections/List): platform.UIKit/UIViewController // com.google.maps.android.compose.multiplatform/GoogleMapViewController|GoogleMapViewController(kotlin.Double;kotlin.Double;kotlin.Float;com.google.maps.android.compose.multiplatform.MapType;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.collections.List){}[0] +final fun com.google.maps.android.compose.multiplatform/GoogleMapViewController(kotlin/Double, kotlin/Double, kotlin/Float, kotlin/Double?, kotlin/Double?, kotlin/String?): platform.UIKit/UIViewController // com.google.maps.android.compose.multiplatform/GoogleMapViewController|GoogleMapViewController(kotlin.Double;kotlin.Double;kotlin.Float;kotlin.Double?;kotlin.Double?;kotlin.String?){}[0] +final fun com.google.maps.android.compose.multiplatform/clusterMarkers(kotlin.collections/List, kotlin/Float, kotlin/Int = ...): kotlin.collections/List // com.google.maps.android.compose.multiplatform/clusterMarkers|clusterMarkers(kotlin.collections.List;kotlin.Float;kotlin.Int){}[0] +final fun com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapMarker$stableprop_getter(): kotlin/Int // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapMarker$stableprop_getter|com_google_maps_android_compose_multiplatform_MapMarker$stableprop_getter(){}[0] diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index 95ac9c9e1..447dd5391 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -15,27 +15,22 @@ */ plugins { - kotlin("multiplatform") + id("android.maps.compose.KmpPublishingConventionPlugin") kotlin("native.cocoapods") - id("com.android.kotlin.multiplatform.library") alias(libs.plugins.compose.compiler) } kotlin { - androidLibrary { + android { namespace = "com.google.maps.android.compose.multiplatform" compileSdk = libs.versions.androidCompileSdk.get().toInt() minSdk = libs.versions.androidMinSdk.get().toInt() } - - // Enable iOS targets. No iosX64 (Intel simulators): Compose Multiplatform dropped it in 1.11. - iosArm64() - iosSimulatorArm64() cocoapods { summary = "Multiplatform Google Maps wrapper" homepage = "https://github.com/googlemaps/android-maps-compose" - version = "1.0" + version = project.version.toString() ios.deploymentTarget = "16.0" pod("GoogleMaps") { version = "10.14.0.0" @@ -87,3 +82,21 @@ kotlin { } } } + +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." + } + } +} From 9087d81c26556743dd512e7802b711e8211cd442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 2 Oct 2026 07:33:42 +0200 Subject: [PATCH 10/14] test: run the multiplatform clustering tests on the JVM and iOS, document the KMP build - commonTest for clusterMarkers, run as Android host tests and on the iOS simulator - iosApp/README.md replaces BUILD_KMP.md; the Xcode project is generated by create_project.rb and no longer checked in - AGENTS.md describes the multiplatform module, its API checks and the snapshot dependency --- AGENTS.md | 24 + BUILD_KMP.md | 83 ---- gradle/libs.versions.toml | 1 + iosApp/.gitignore | 3 + iosApp/README.md | 50 +++ iosApp/iosApp.xcodeproj/project.pbxproj | 419 ------------------ maps-compose-multiplatform/build.gradle.kts | 11 + .../multiplatform/ClusterMarkersTest.kt | 89 ++++ 8 files changed, 178 insertions(+), 502 deletions(-) delete mode 100644 BUILD_KMP.md create mode 100644 iosApp/README.md delete mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusterMarkersTest.kt diff --git a/AGENTS.md b/AGENTS.md index 4a58c7c10..032ce4ce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,9 @@ 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-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 +30,28 @@ 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. + 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/BUILD_KMP.md b/BUILD_KMP.md deleted file mode 100644 index a68effcad..000000000 --- a/BUILD_KMP.md +++ /dev/null @@ -1,83 +0,0 @@ -# Kotlin Multiplatform (KMP) Maps Demo โ€” Build & Run Guide - -This guide details how to build and run the multiplatform Maps Compose library (`:maps-compose-multiplatform`), the Android demo app (`:maps-app`), and the iOS demo app (`iosApp`). - ---- - -## 1. Checkout the PR / Branch - -Fetch the remote branch and check it out (or add it as a Git worktree): - -```bash -git fetch origin feat/experimental-kmp-module -git checkout feat/experimental-kmp-module -``` - -*(Alternatively, to isolate your work in a worktree branch)*: -```bash -git worktree add -b feat/experimental-kmp-module feat-experimental-kmp-module origin/feat/experimental-kmp-module -``` - ---- - -## 2. Configure Local API Keys - -Create a file named `secrets.properties` in the **root** directory of the repository (if you don't already have one) and populate it with your Google Maps API keys: - -```properties -MAPS_API_KEY=YOUR_GOOGLE_MAPS_API_KEY -PLACES_API_KEY=YOUR_GOOGLE_PLACES_API_KEY -``` - -> [!IMPORTANT] -> Both `secrets.properties` and the generated iOS equivalent (`DeveloperSecrets.swift`) are ignored by Git to prevent accidentally leaking credentials. - ---- - -## 3. Building & Running for Android - -To compile and verify both the KMP library Android target and the Android sample application (`:maps-app`), run: - -```bash -./gradlew assembleDebug -``` - ---- - -## 4. Building & Running for iOS (Step-by-Step) - -If you are running the iOS project (`iosApp`) for the first time, follow these steps on a Mac with **Xcode** and **CocoaPods** installed: - -### Step 4.1: Generate the KMP Dummy Framework -Before CocoaPods can link the local `:maps-compose-multiplatform` podspec, the initial Kotlin framework must exist. From the repository root, execute: - -```bash -./gradlew :maps-compose-multiplatform:generateDummyFramework -``` - -### Step 4.2: Generate the Xcode Project -Navigate to the `iosApp` directory and run the project generation Ruby script (requires the `xcodeproj` gem): - -```bash -cd iosApp -ruby create_project.rb -``` - -> [!NOTE] -> **Build System Output Tracking (Xcode 16+)**: The `create_project.rb` script declares `$(SRCROOT)/iosApp/DeveloperSecrets.swift` as an output of the **Populate Secrets** script build phase. This prevents modern Xcode build systems from failing with `Build input file cannot be found: DeveloperSecrets.swift` during dependency graph analysis. - -### Step 4.3: Install Pod Dependencies -Install the required CocoaPods (`GoogleMaps` 10.14.0 and the local KMP library). If your local specs repository is out of date, use the `--repo-update` flag: - -```bash -pod install --repo-update -``` - -### Step 4.4: Open and Run in Xcode -1. Open the generated **Workspace** (do **not** open the `.xcodeproj` file directly): - ```bash - open iosApp.xcworkspace - ``` -2. In Xcode's top toolbar, select the active scheme dropdown and set it to **`iosApp`** (instead of `maps_compose_multiplatform`). -3. Choose an iOS Simulator (e.g., **iPhone 16** or **iPhone 17 Pro**) in the destination dropdown. -4. Click the **Play** button (or press **Cmd + R**) to compile, run the secrets population script, and launch the app! diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c2aacbec..d645d93d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ binary-compatibility-validator = { module = "org.jetbrains.kotlinx.binary-compat 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" } diff --git a/iosApp/.gitignore b/iosApp/.gitignore index 5d5a012c8..e1d48ef7b 100644 --- a/iosApp/.gitignore +++ b/iosApp/.gitignore @@ -17,3 +17,6 @@ xcuserdata/ # Secrets iosApp/DeveloperSecrets.swift + +# Generated by create_project.rb +iosApp.xcodeproj/ diff --git a/iosApp/README.md b/iosApp/README.md new file mode 100644 index 000000000..b9ab9a807 --- /dev/null +++ b/iosApp/README.md @@ -0,0 +1,50 @@ +# iOS demo app + +Demos of `maps-compose-multiplatform` on iOS, the counterpart of the multiplatform demo in +`maps-app`. 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: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 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/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj deleted file mode 100644 index c8ac885ee..000000000 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ /dev/null @@ -1,419 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - 0D653230C7B4B77DED78F795 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B55B65E85F954E4BF49E7CA /* AppDelegate.swift */; }; - 5520C2B8920F1DE6E2146F81 /* SampleListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75DDC1C063EA32CE8824D07F /* SampleListViewController.swift */; }; - 7DE9F50AB020D738F9AC35AF /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7AB792F01E14CD3EEC1AF5B8 /* Pods_iosApp.framework */; }; - 9B39CDB0583E7EFC46398A00 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D66C3B8A1626E64624FFC75 /* Foundation.framework */; }; - C048757D5C4B50BC1AB38AE6 /* DeveloperSecrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C4DBA12C4C8AAC40F8EE45 /* DeveloperSecrets.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 2E61E10ADF7CF13E32E000C0 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; - 4D66C3B8A1626E64624FFC75 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - 51C4DBA12C4C8AAC40F8EE45 /* DeveloperSecrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeveloperSecrets.swift; sourceTree = ""; }; - 6B55B65E85F954E4BF49E7CA /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 711AFBB8CF132FD69AED5EAE /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 75DDC1C063EA32CE8824D07F /* SampleListViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleListViewController.swift; sourceTree = ""; }; - 7AB792F01E14CD3EEC1AF5B8 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 7FA1089A5A50A88C1B0534CC /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; - B84637CCA3496DDB1F800E10 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 755A4F11212F18CBAE1A61A0 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 9B39CDB0583E7EFC46398A00 /* Foundation.framework in Frameworks */, - 7DE9F50AB020D738F9AC35AF /* Pods_iosApp.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 6572DE9AD86B0CC67C7E963E /* Frameworks */ = { - isa = PBXGroup; - children = ( - C469B336BFDB55F88E4144D1 /* iOS */, - 7AB792F01E14CD3EEC1AF5B8 /* Pods_iosApp.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - 8F4470E4F5DD69A5F2052318 /* iosApp */ = { - isa = PBXGroup; - children = ( - 6B55B65E85F954E4BF49E7CA /* AppDelegate.swift */, - 75DDC1C063EA32CE8824D07F /* SampleListViewController.swift */, - 51C4DBA12C4C8AAC40F8EE45 /* DeveloperSecrets.swift */, - 711AFBB8CF132FD69AED5EAE /* Info.plist */, - ); - name = iosApp; - path = iosApp; - sourceTree = ""; - }; - 9C595BA209CE46C978747670 = { - isa = PBXGroup; - children = ( - F5812B1B51C667A99E3473DB /* Products */, - 6572DE9AD86B0CC67C7E963E /* Frameworks */, - 8F4470E4F5DD69A5F2052318 /* iosApp */, - C21DC034EFC3BBCE3CB62ECF /* Pods */, - ); - sourceTree = ""; - }; - C21DC034EFC3BBCE3CB62ECF /* Pods */ = { - isa = PBXGroup; - children = ( - 7FA1089A5A50A88C1B0534CC /* Pods-iosApp.release.xcconfig */, - 2E61E10ADF7CF13E32E000C0 /* Pods-iosApp.debug.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - C469B336BFDB55F88E4144D1 /* iOS */ = { - isa = PBXGroup; - children = ( - 4D66C3B8A1626E64624FFC75 /* Foundation.framework */, - ); - name = iOS; - sourceTree = ""; - }; - F5812B1B51C667A99E3473DB /* Products */ = { - isa = PBXGroup; - children = ( - B84637CCA3496DDB1F800E10 /* iosApp.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - F5CA17578FAB66DC77D30B26 /* iosApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = CF3EFFA659DA87ADF491844D /* Build configuration list for PBXNativeTarget "iosApp" */; - buildPhases = ( - ABA2E729135D4636D5EBF68A /* [CP] Check Pods Manifest.lock */, - EA513F9C2ABA8D7F7B488510 /* Populate Secrets */, - C8810296C81A419006D89E5E /* Sources */, - 755A4F11212F18CBAE1A61A0 /* Frameworks */, - C6F77023AAF8823528A70ED2 /* Resources */, - D63BC68F2C76415CBAB80FEA /* [CP] Copy Pods Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = iosApp; - productName = iosApp; - productReference = B84637CCA3496DDB1F800E10 /* iosApp.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 0BAC57553145A9D99D214F34 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 1600; - LastUpgradeCheck = 1600; - }; - buildConfigurationList = 2F8B9FD9291D2E34BAB2808B /* Build configuration list for PBXProject "iosApp" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 9C595BA209CE46C978747670; - minimizedProjectReferenceProxies = 0; - preferredProjectObjectVersion = 77; - productRefGroup = F5812B1B51C667A99E3473DB /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - F5CA17578FAB66DC77D30B26 /* iosApp */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - C6F77023AAF8823528A70ED2 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - ABA2E729135D4636D5EBF68A /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - D63BC68F2C76415CBAB80FEA /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/GoogleMaps/GoogleMapsResources.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleMapsResources.bundle", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - EA513F9C2ABA8D7F7B488510 /* Populate Secrets */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Populate Secrets"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(SRCROOT)/iosApp/DeveloperSecrets.swift", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "SECRETS_PATH=\"${PROJECT_DIR}/../secrets.properties\"\nOUTPUT_FILE=\"${SRCROOT}/iosApp/DeveloperSecrets.swift\"\n\nAPI_KEY=\"YOUR_API_KEY\"\n\nif [ -f \"$SECRETS_PATH\" ]; then\n EXTRACTED_KEY=$(grep -E \"^MAPS_API_KEY=\" \"$SECRETS_PATH\" | cut -d'=' -f2 | tr -d '\"' | tr -d \"'\")\n if [ ! -z \"$EXTRACTED_KEY\" ]; then\n API_KEY=\"$EXTRACTED_KEY\"\n fi\nfi\n\ncat < \"$OUTPUT_FILE\"\n// Generated file. Do not commit or modify.\nstruct DeveloperSecrets {\n static let mapsApiKey = \"$API_KEY\"\n}\nEOF\n"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - C8810296C81A419006D89E5E /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 0D653230C7B4B77DED78F795 /* AppDelegate.swift in Sources */, - 5520C2B8920F1DE6E2146F81 /* SampleListViewController.swift in Sources */, - C048757D5C4B50BC1AB38AE6 /* DeveloperSecrets.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 30D83033DD758DCAE2B65F37 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 79F5FD2A2E53F2F285DB566F /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7FA1089A5A50A88C1B0534CC /* Pods-iosApp.release.xcconfig */; - buildSettings = { - AD_HOC_CODE_SIGNING_ALLOWED = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGNING_ALLOWED = NO; - CODE_SIGNING_REQUIRED = NO; - INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.google.maps.android.compose.iosApp; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 911DF9EA0DD218A18147B490 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - BE70C656E6CD2E4A26E99D95 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 2E61E10ADF7CF13E32E000C0 /* Pods-iosApp.debug.xcconfig */; - buildSettings = { - AD_HOC_CODE_SIGNING_ALLOWED = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGNING_ALLOWED = NO; - CODE_SIGNING_REQUIRED = NO; - INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.google.maps.android.compose.iosApp; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 2F8B9FD9291D2E34BAB2808B /* Build configuration list for PBXProject "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 911DF9EA0DD218A18147B490 /* Debug */, - 30D83033DD758DCAE2B65F37 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - CF3EFFA659DA87ADF491844D /* Build configuration list for PBXNativeTarget "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 79F5FD2A2E53F2F285DB566F /* Release */, - BE70C656E6CD2E4A26E99D95 /* Debug */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 0BAC57553145A9D99D214F34 /* Project object */; -} diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index 447dd5391..c6d188e53 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -25,6 +25,12 @@ kotlin { 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 { @@ -69,6 +75,11 @@ kotlin { api(libs.maps.utils.kmp.clustering) } } + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } androidMain { dependencies { // Link the existing maps-compose module locally diff --git a/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusterMarkersTest.kt b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusterMarkersTest.kt new file mode 100644 index 000000000..0a9c7be9f --- /dev/null +++ b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusterMarkersTest.kt @@ -0,0 +1,89 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +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 ClusterMarkersTest { + private val goldenGate = MapMarker(37.8199, -122.4783, title = "Golden Gate") + private val alcatraz = MapMarker(37.8267, -122.4230, title = "Alcatraz") + private val coitTower = MapMarker(37.8024, -122.4058, title = "Coit Tower") + private val london = MapMarker(51.5007, -0.1246, title = "Big Ben") + + @Test + fun noMarkersGiveNoClusters() { + assertTrue(clusterMarkers(emptyList(), zoom = 10f).isEmpty()) + } + + @Test + fun aSingleMarkerIsReturnedUnchanged() { + val result = clusterMarkers(listOf(goldenGate), zoom = 10f) + + assertSame(goldenGate, result.single()) + } + + @Test + fun nearbyMarkersMergeWhenZoomedOut() { + val result = clusterMarkers(listOf(goldenGate, alcatraz, coitTower), zoom = 5f) + + val cluster = result.single() + assertEquals("3 items", cluster.title) + } + + @Test + fun nearbyMarkersSplitWhenZoomedIn() { + val markers = listOf(goldenGate, alcatraz, coitTower) + + val result = clusterMarkers(markers, zoom = 18f) + + assertEquals(markers.toSet(), result.toSet()) + } + + @Test + fun distantMarkersNeverMerge() { + val result = clusterMarkers(listOf(goldenGate, london), zoom = 3f) + + assertEquals(setOf(goldenGate, london), result.toSet()) + } + + @Test + fun aLargerDistanceMergesMoreMarkers() { + val markers = listOf(goldenGate, alcatraz, coitTower) + + val tight = clusterMarkers(markers, zoom = 12f, maxDistanceBetweenClusteredItems = 1) + val loose = clusterMarkers(markers, zoom = 12f, maxDistanceBetweenClusteredItems = 1_000) + + assertEquals(3, tight.size) + assertEquals(1, loose.size) + } + + @Test + fun aClusterIsPlacedAtOneOfItsMarkers() { + val markers = listOf(goldenGate, alcatraz, coitTower) + + val cluster = clusterMarkers(markers, zoom = 5f).single() + + assertTrue(markers.any { it.latitude == cluster.latitude && it.longitude == cluster.longitude }) + } +} From 8b43d3efe54761f0302b69052a5a2c35c10e96ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 2 Oct 2026 08:28:00 +0200 Subject: [PATCH 11/14] feat: shared camera state, map content and clustering in maps-compose-multiplatform Replaces the prototype API with one that follows maps-compose: - GoogleMap(cameraPositionState, properties, uiSettings, onMapClick, onMapLongClick, onMapLoaded) { content } - CameraPositionState follows the map both ways and can animate - Marker with MarkerState (draggable, tappable, tinted pin), Polyline, Polygon with holes and Circle; each keeps one native object per call instead of clearing the map on every update - Clustering reclusters as the camera zooms and zooms in on cluster taps - Line widths are density-independent on both platforms Demos move to maps-compose-multiplatform-demo, shared by maps-app and the iOS app, which now hosts them instead of a Swift list. Tests cover the camera state, content registration and clustering on the JVM and iOS. --- .github/workflows/multiplatform.yml | 4 +- AGENTS.md | 8 + README.md | 48 +++ iosApp/Podfile | 13 +- iosApp/README.md | 16 +- iosApp/create_project.rb | 5 +- iosApp/iosApp/AppDelegate.swift | 8 +- iosApp/iosApp/SampleListViewController.swift | 223 -------------- maps-app/build.gradle.kts | 2 +- .../maps/android/compose/KmpMapActivity.kt | 28 +- maps-app/src/main/res/values/strings.xml | 4 +- .../build.gradle.kts | 60 ++++ .../maps_compose_multiplatform_demo.podspec | 46 +++ .../compose/multiplatform/demo/DemoApp.kt | 173 +++++++++++ .../compose/multiplatform/demo/Demos.kt | 274 ++++++++++++++++++ .../multiplatform/demo/MainViewController.kt | 39 +++ .../api/maps-compose-multiplatform.api | 96 ++++-- .../api/maps-compose-multiplatform.klib.api | 117 ++++++-- maps-compose-multiplatform/build.gradle.kts | 1 + .../maps_compose_multiplatform.podspec | 4 +- .../compose/multiplatform/GoogleMap.kt | 80 ----- .../PlatformGoogleMap.android.kt | 203 +++++++++++++ .../multiplatform/CameraPositionState.kt | 131 +++++++++ .../compose/multiplatform/Clustering.kt | 164 ++++++----- .../compose/multiplatform/GoogleMap.kt | 87 ++++-- .../compose/multiplatform/MapContent.kt | 262 +++++++++++++++++ .../compose/multiplatform/MapProperties.kt | 119 ++++++++ .../multiplatform/CameraPositionStateTest.kt | 120 ++++++++ .../multiplatform/ClusterMarkersTest.kt | 89 ------ .../compose/multiplatform/ClusteringTest.kt | 110 +++++++ .../compose/multiplatform/MapContentTest.kt | 117 ++++++++ .../multiplatform/MapContentTestHarness.kt | 92 ++++++ .../compose/multiplatform/GoogleMap.kt | 159 ---------- .../compose/multiplatform/MapNodeRenderer.kt | 164 +++++++++++ .../compose/multiplatform/MapViewDelegate.kt | 129 +++++++++ .../multiplatform/PlatformGoogleMap.ios.kt | 145 +++++++++ settings.gradle.kts | 1 + 37 files changed, 2610 insertions(+), 731 deletions(-) delete mode 100644 iosApp/iosApp/SampleListViewController.swift create mode 100644 maps-compose-multiplatform-demo/build.gradle.kts create mode 100644 maps-compose-multiplatform-demo/maps_compose_multiplatform_demo.podspec create mode 100644 maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt create mode 100644 maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt create mode 100644 maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/MainViewController.kt delete mode 100644 maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt create mode 100644 maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.android.kt create mode 100644 maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionState.kt create mode 100644 maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapContent.kt create mode 100644 maps-compose-multiplatform/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/MapProperties.kt create mode 100644 maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/CameraPositionStateTest.kt delete mode 100644 maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusterMarkersTest.kt create mode 100644 maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusteringTest.kt create mode 100644 maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTest.kt create mode 100644 maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/MapContentTestHarness.kt delete mode 100644 maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt create mode 100644 maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapNodeRenderer.kt create mode 100644 maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/MapViewDelegate.kt create mode 100644 maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/PlatformGoogleMap.ios.kt diff --git a/.github/workflows/multiplatform.yml b/.github/workflows/multiplatform.yml index e35b287e4..0954dd038 100644 --- a/.github/workflows/multiplatform.yml +++ b/.github/workflows/multiplatform.yml @@ -52,13 +52,14 @@ jobs: :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:generateDummyFramework) + (cd .. && ./gradlew :maps-compose-multiplatform-demo:generateDummyFramework) ruby create_project.rb pod install xcodebuild build \ @@ -67,4 +68,5 @@ jobs: -sdk iphonesimulator \ -destination 'generic/platform=iOS Simulator' \ -configuration Debug \ + ARCHS=arm64 \ CODE_SIGNING_ALLOWED=NO diff --git a/AGENTS.md b/AGENTS.md index 032ce4ce5..cbccc1a0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ libraries live in three modules; the rest of the repo supports them. | `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 | @@ -51,6 +52,13 @@ Google Maps SDK for iOS through CocoaPods). Its iOS targets only build on macOS, 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 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/iosApp/Podfile b/iosApp/Podfile index 058583d95..0e926f5ad 100644 --- a/iosApp/Podfile +++ b/iosApp/Podfile @@ -6,6 +6,15 @@ inhibit_all_warnings! target 'iosApp' do use_frameworks! - # Reference our local KMP module via its podspec path - pod 'maps_compose_multiplatform', :path => '../maps-compose-multiplatform' + # 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 index b9ab9a807..570409c3c 100644 --- a/iosApp/README.md +++ b/iosApp/README.md @@ -1,8 +1,10 @@ # iOS demo app -Demos of `maps-compose-multiplatform` on iOS, the counterpart of the multiplatform demo in -`maps-app`. It needs a Mac with Xcode, CocoaPods and the `xcodeproj` gem (installed with -CocoaPods). +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 @@ -18,7 +20,7 @@ CocoaPods). 2. From the repository root, create the placeholder framework CocoaPods links against: ```bash - ./gradlew :maps-compose-multiplatform:generateDummyFramework + ./gradlew :maps-compose-multiplatform-demo:generateDummyFramework ``` 3. Generate the Xcode project and install the pods: @@ -35,6 +37,12 @@ CocoaPods). 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 diff --git a/iosApp/create_project.rb b/iosApp/create_project.rb index cf90bfb1c..b116dc228 100644 --- a/iosApp/create_project.rb +++ b/iosApp/create_project.rb @@ -14,7 +14,6 @@ # Reference source files inside the group app_delegate_ref = group.new_file('AppDelegate.swift') -sample_list_ref = group.new_file('SampleListViewController.swift') secrets_ref = group.new_file('DeveloperSecrets.swift') info_plist_ref = group.new_file('Info.plist') @@ -31,6 +30,9 @@ 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 @@ -64,7 +66,6 @@ # 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(sample_list_ref) source_build_phase.add_file_reference(secrets_ref) project.save diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index d206fe8fb..c37d03aba 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -1,6 +1,6 @@ import UIKit import GoogleMaps -import maps_compose_multiplatform +import maps_compose_multiplatform_demo @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -14,10 +14,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { GMSServices.provideAPIKey(DeveloperSecrets.mapsApiKey) window = UIWindow(frame: UIScreen.main.bounds) - let sampleListVC = SampleListViewController() - let navController = UINavigationController(rootViewController: sampleListVC) - - window?.rootViewController = navController + // 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/SampleListViewController.swift b/iosApp/iosApp/SampleListViewController.swift deleted file mode 100644 index cc7a53a9c..000000000 --- a/iosApp/iosApp/SampleListViewController.swift +++ /dev/null @@ -1,223 +0,0 @@ -import UIKit -import GoogleMaps -import maps_compose_multiplatform - -class SampleListViewController: UITableViewController { - - struct Sample { - let title: String - let description: String - let latitude: Double - let longitude: Double - let zoom: Float - let mapType: MapType - let myLocationEnabled: Bool - let scrollGesturesEnabled: Bool - let zoomGesturesEnabled: Bool - let markers: [MapMarker] - } - - struct Section { - let title: String - let samples: [Sample] - } - - private var sections: [Section] = [] - - override func viewDidLoad() { - super.viewDidLoad() - self.title = "KMP Maps Demos" - - setupSections() - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - } - - private func setupSections() { - sections = [ - Section( - title: "Map Types", - samples: [ - Sample( - title: "Normal Map", - description: "Standard road map of San Francisco", - latitude: 37.7749, - longitude: -122.4194, - zoom: 12.0, - mapType: .normal, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [] - ), - Sample( - title: "Satellite Map", - description: "Satellite imagery of the Grand Canyon", - latitude: 36.0544, - longitude: -112.1401, - zoom: 10.0, - mapType: .satellite, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [] - ), - Sample( - title: "Hybrid Map", - description: "Satellite with road names in New York City", - latitude: 40.7128, - longitude: -74.0060, - zoom: 11.0, - mapType: .hybrid, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [] - ), - Sample( - title: "Terrain Map", - description: "Topographic map of Mount Everest", - latitude: 27.9881, - longitude: 86.9250, - zoom: 10.0, - mapType: .terrain, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [] - ) - ] - ), - Section( - title: "Markers & Pins", - samples: [ - Sample( - title: "Single Marker", - description: "Marker at Golden Gate Bridge", - latitude: 37.8199, - longitude: -122.4783, - zoom: 13.0, - mapType: .normal, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [ - MapMarker(latitude: 37.8199, longitude: -122.4783, title: "Golden Gate Bridge", snippet: "San Francisco, CA") - ] - ), - Sample( - title: "Multiple Markers", - description: "Demos with Big Ben, Tower Bridge, London Eye", - latitude: 51.5033, - longitude: -0.1195, - zoom: 13.0, - mapType: .normal, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [ - MapMarker(latitude: 51.5007, longitude: -0.1246, title: "Big Ben", snippet: "Historic Clock Tower"), - MapMarker(latitude: 51.5055, longitude: -0.0754, title: "Tower Bridge", snippet: "Famous suspension bridge"), - MapMarker(latitude: 51.5033, longitude: -0.1195, title: "London Eye", snippet: "Giant Ferris Wheel") - ] - ), - Sample( - title: "Custom Snippet Marker", - description: "Tokyo Center with descriptive marker information", - latitude: 35.6762, - longitude: 139.6503, - zoom: 11.0, - mapType: .normal, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [ - MapMarker(latitude: 35.6762, longitude: 139.6503, title: "Tokyo City", snippet: "Population: 14 million people") - ] - ) - ] - ), - Section( - title: "Map Gestures & Controls", - samples: [ - Sample( - title: "Gestures Enabled (Default)", - description: "Fully interactive map with zoom and scroll support", - latitude: 48.8566, - longitude: 2.3522, - zoom: 12.0, - mapType: .normal, - myLocationEnabled: false, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [] - ), - Sample( - title: "Gestures Disabled", - description: "Static map centered on Rome, Italy (Scroll & zoom locked)", - latitude: 41.9028, - longitude: 12.4964, - zoom: 12.0, - mapType: .normal, - myLocationEnabled: false, - scrollGesturesEnabled: false, - zoomGesturesEnabled: false, - markers: [] - ), - Sample( - title: "Show My Location Button", - description: "Request location services & displays Location Button", - latitude: 48.8566, - longitude: 2.3522, - zoom: 12.0, - mapType: .normal, - myLocationEnabled: true, - scrollGesturesEnabled: true, - zoomGesturesEnabled: true, - markers: [] - ) - ] - ) - ] - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return sections.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return sections[section].samples.count - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - return sections[section].title - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "Cell") - let sample = sections[indexPath.section].samples[indexPath.row] - cell.textLabel?.text = sample.title - cell.detailTextLabel?.text = sample.description - cell.accessoryType = .disclosureIndicator - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let sample = sections[indexPath.section].samples[indexPath.row] - - let mapViewController = GoogleMapKt.GoogleMapViewController( - latitude: sample.latitude, - longitude: sample.longitude, - zoom: sample.zoom, - mapType: sample.mapType, - myLocationEnabled: sample.myLocationEnabled, - scrollGesturesEnabled: sample.scrollGesturesEnabled, - zoomGesturesEnabled: sample.zoomGesturesEnabled, - markers: sample.markers - ) - - mapViewController.title = sample.title - self.navigationController?.pushViewController(mapViewController, animated: true) - } -} diff --git a/maps-app/build.gradle.kts b/maps-app/build.gradle.kts index e3cb4193e..c6a4142d3 100644 --- a/maps-app/build.gradle.kts +++ b/maps-app/build.gradle.kts @@ -133,7 +133,7 @@ dependencies { implementation(project(":maps-compose")) implementation(project(":maps-compose-widgets")) implementation(project(":maps-compose-utils")) - implementation(project(":maps-compose-multiplatform")) + implementation(project(":maps-compose-multiplatform-demo")) } secrets { 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 index 37c616e65..a0e92f031 100644 --- 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 @@ -19,31 +19,19 @@ package com.google.maps.android.compose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import com.google.maps.android.compose.multiplatform.GoogleMap -import com.google.maps.android.compose.multiplatform.MapMarker +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 { - // Renders the multiplatform Map Composable - GoogleMap( - modifier = Modifier.fillMaxSize(), - latitude = 37.7749, // San Francisco - longitude = -122.4194, - zoom = 12f, - markers = listOf( - MapMarker( - latitude = 37.7749, - longitude = -122.4194, - title = "San Francisco", - snippet = "Welcome to SF!" - ) - ) - ) + 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 b3e606257..1561f4418 100644 --- a/maps-app/src/main/res/values/strings.xml +++ b/maps-app/src/main/res/values/strings.xml @@ -84,8 +84,8 @@ Ground Overlay Adding a ground overlay to the map. - KMP Multiplatform Map - A map showcasing the KMP GoogleMap wrapper. + Multiplatform demos + The maps-compose-multiplatform demos, shared with the iOS demo app. Map Types 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/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..625e1f956 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt @@ -0,0 +1,173 @@ +/* + * 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() + }, +) + +/** + * 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..2345a4320 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt @@ -0,0 +1,274 @@ +/* + * 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.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.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" 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/api/maps-compose-multiplatform.api b/maps-compose-multiplatform/api/maps-compose-multiplatform.api index 601061231..fde0e189e 100644 --- a/maps-compose-multiplatform/api/maps-compose-multiplatform.api +++ b/maps-compose-multiplatform/api/maps-compose-multiplatform.api @@ -1,29 +1,66 @@ +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 ClusteredGoogleMap (Landroidx/compose/ui/Modifier;DDFLcom/google/maps/android/compose/multiplatform/MapType;ZZZLjava/util/List;ILandroidx/compose/runtime/Composer;II)V - public static final fun clusterMarkers (Ljava/util/List;FI)Ljava/util/List; - public static synthetic fun clusterMarkers$default (Ljava/util/List;FIILjava/lang/Object;)Ljava/util/List; + 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;DDFLcom/google/maps/android/compose/multiplatform/MapType;ZZZLjava/util/List;Landroidx/compose/runtime/Composer;II)V + 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/MapMarker { +public final class com/google/maps/android/compose/multiplatform/MapProperties { public static final field $stable I - public fun (DDLjava/lang/String;Ljava/lang/String;)V - public synthetic fun (DDLjava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()D - public final fun component2 ()D - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/lang/String; - public final fun copy (DDLjava/lang/String;Ljava/lang/String;)Lcom/google/maps/android/compose/multiplatform/MapMarker; - public static synthetic fun copy$default (Lcom/google/maps/android/compose/multiplatform/MapMarker;DDLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/google/maps/android/compose/multiplatform/MapMarker; + 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 getLatitude ()D - public final fun getLongitude ()D - public final fun getSnippet ()Ljava/lang/String; - public final fun getTitle ()Ljava/lang/String; + 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; } @@ -38,3 +75,28 @@ public final class com/google/maps/android/compose/multiplatform/MapType : java/ 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 index 9d57ef8f5..2ee454b00 100644 --- a/maps-compose-multiplatform/api/maps-compose-multiplatform.klib.api +++ b/maps-compose-multiplatform/api/maps-compose-multiplatform.klib.api @@ -20,34 +20,95 @@ final enum class com.google.maps.android.compose.multiplatform/MapType : kotlin/ final fun values(): kotlin/Array // com.google.maps.android.compose.multiplatform/MapType.values|values#static(){}[0] } -final class com.google.maps.android.compose.multiplatform/MapMarker { // com.google.maps.android.compose.multiplatform/MapMarker|null[0] - constructor (kotlin/Double, kotlin/Double, kotlin/String? = ..., kotlin/String? = ...) // com.google.maps.android.compose.multiplatform/MapMarker.|(kotlin.Double;kotlin.Double;kotlin.String?;kotlin.String?){}[0] - - final val latitude // com.google.maps.android.compose.multiplatform/MapMarker.latitude|{}latitude[0] - final fun (): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.latitude.|(){}[0] - final val longitude // com.google.maps.android.compose.multiplatform/MapMarker.longitude|{}longitude[0] - final fun (): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.longitude.|(){}[0] - final val snippet // com.google.maps.android.compose.multiplatform/MapMarker.snippet|{}snippet[0] - final fun (): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.snippet.|(){}[0] - final val title // com.google.maps.android.compose.multiplatform/MapMarker.title|{}title[0] - final fun (): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.title.|(){}[0] - - final fun component1(): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.component1|component1(){}[0] - final fun component2(): kotlin/Double // com.google.maps.android.compose.multiplatform/MapMarker.component2|component2(){}[0] - final fun component3(): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.component3|component3(){}[0] - final fun component4(): kotlin/String? // com.google.maps.android.compose.multiplatform/MapMarker.component4|component4(){}[0] - final fun copy(kotlin/Double = ..., kotlin/Double = ..., kotlin/String? = ..., kotlin/String? = ...): com.google.maps.android.compose.multiplatform/MapMarker // com.google.maps.android.compose.multiplatform/MapMarker.copy|copy(kotlin.Double;kotlin.Double;kotlin.String?;kotlin.String?){}[0] - final fun equals(kotlin/Any?): kotlin/Boolean // com.google.maps.android.compose.multiplatform/MapMarker.equals|equals(kotlin.Any?){}[0] - final fun hashCode(): kotlin/Int // com.google.maps.android.compose.multiplatform/MapMarker.hashCode|hashCode(){}[0] - final fun toString(): kotlin/String // com.google.maps.android.compose.multiplatform/MapMarker.toString|toString(){}[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_MapMarker$stableprop // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapMarker$stableprop|#static{}com_google_maps_android_compose_multiplatform_MapMarker$stableprop[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 com.google.maps.android.compose.multiplatform/ClusteredGoogleMap(androidx.compose.ui/Modifier?, kotlin/Double, kotlin/Double, kotlin/Float, com.google.maps.android.compose.multiplatform/MapType?, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin.collections/List?, kotlin/Int, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/ClusteredGoogleMap|ClusteredGoogleMap(androidx.compose.ui.Modifier?;kotlin.Double;kotlin.Double;kotlin.Float;com.google.maps.android.compose.multiplatform.MapType?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.collections.List?;kotlin.Int;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] -final fun com.google.maps.android.compose.multiplatform/GoogleMap(androidx.compose.ui/Modifier?, kotlin/Double, kotlin/Double, kotlin/Float, com.google.maps.android.compose.multiplatform/MapType?, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin.collections/List?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.google.maps.android.compose.multiplatform/GoogleMap|GoogleMap(androidx.compose.ui.Modifier?;kotlin.Double;kotlin.Double;kotlin.Float;com.google.maps.android.compose.multiplatform.MapType?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.collections.List?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] -final fun com.google.maps.android.compose.multiplatform/GoogleMapViewController(kotlin/Double, kotlin/Double, kotlin/Float): platform.UIKit/UIViewController // com.google.maps.android.compose.multiplatform/GoogleMapViewController|GoogleMapViewController(kotlin.Double;kotlin.Double;kotlin.Float){}[0] -final fun com.google.maps.android.compose.multiplatform/GoogleMapViewController(kotlin/Double, kotlin/Double, kotlin/Float, com.google.maps.android.compose.multiplatform/MapType, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin.collections/List): platform.UIKit/UIViewController // com.google.maps.android.compose.multiplatform/GoogleMapViewController|GoogleMapViewController(kotlin.Double;kotlin.Double;kotlin.Float;com.google.maps.android.compose.multiplatform.MapType;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.collections.List){}[0] -final fun com.google.maps.android.compose.multiplatform/GoogleMapViewController(kotlin/Double, kotlin/Double, kotlin/Float, kotlin/Double?, kotlin/Double?, kotlin/String?): platform.UIKit/UIViewController // com.google.maps.android.compose.multiplatform/GoogleMapViewController|GoogleMapViewController(kotlin.Double;kotlin.Double;kotlin.Float;kotlin.Double?;kotlin.Double?;kotlin.String?){}[0] -final fun com.google.maps.android.compose.multiplatform/clusterMarkers(kotlin.collections/List, kotlin/Float, kotlin/Int = ...): kotlin.collections/List // com.google.maps.android.compose.multiplatform/clusterMarkers|clusterMarkers(kotlin.collections.List;kotlin.Float;kotlin.Int){}[0] -final fun com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapMarker$stableprop_getter(): kotlin/Int // com.google.maps.android.compose.multiplatform/com_google_maps_android_compose_multiplatform_MapMarker$stableprop_getter|com_google_maps_android_compose_multiplatform_MapMarker$stableprop_getter(){}[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 index c6d188e53..b8b5ebc2b 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -78,6 +78,7 @@ kotlin { commonTest { dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } androidMain { diff --git a/maps-compose-multiplatform/maps_compose_multiplatform.podspec b/maps-compose-multiplatform/maps_compose_multiplatform.podspec index 86cfb6f6c..1d28cb5d9 100644 --- a/maps-compose-multiplatform/maps_compose_multiplatform.podspec +++ b/maps-compose-multiplatform/maps_compose_multiplatform.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'maps_compose_multiplatform' - spec.version = '1.0' + spec.version = '9.0.0' spec.homepage = 'https://github.com/googlemaps/android-maps-compose' spec.source = { :http=> ''} spec.authors = '' @@ -8,7 +8,7 @@ Pod::Spec.new do |spec| spec.summary = 'Multiplatform Google Maps wrapper' spec.vendored_frameworks = 'build/cocoapods/framework/maps_compose_multiplatform.framework' spec.libraries = 'c++' - spec.ios.deployment_target = '15.0' + 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 " diff --git a/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt b/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt deleted file mode 100644 index 564605539..000000000 --- a/maps-compose-multiplatform/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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.remember -import androidx.compose.ui.Modifier -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.compose.rememberCameraPositionState - -@Composable -public actual fun GoogleMap( - modifier: Modifier, - latitude: Double, - longitude: Double, - zoom: Float, - mapType: MapType, - myLocationEnabled: Boolean, - scrollGesturesEnabled: Boolean, - zoomGesturesEnabled: Boolean, - markers: List -) { - val cameraPositionState = rememberCameraPositionState { - position = CameraPosition.fromLatLngZoom(LatLng(latitude, longitude), zoom) - } - - val mapProperties = remember(mapType, myLocationEnabled) { - com.google.maps.android.compose.MapProperties( - mapType = when (mapType) { - MapType.NONE -> com.google.maps.android.compose.MapType.NONE - MapType.NORMAL -> com.google.maps.android.compose.MapType.NORMAL - MapType.SATELLITE -> com.google.maps.android.compose.MapType.SATELLITE - MapType.TERRAIN -> com.google.maps.android.compose.MapType.TERRAIN - MapType.HYBRID -> com.google.maps.android.compose.MapType.HYBRID - }, - isMyLocationEnabled = myLocationEnabled - ) - } - - val mapUiSettings = remember(scrollGesturesEnabled, zoomGesturesEnabled) { - com.google.maps.android.compose.MapUiSettings( - scrollGesturesEnabled = scrollGesturesEnabled, - zoomGesturesEnabled = zoomGesturesEnabled - ) - } - - com.google.maps.android.compose.GoogleMap( - modifier = modifier, - cameraPositionState = cameraPositionState, - properties = mapProperties, - uiSettings = mapUiSettings - ) { - markers.forEach { markerData -> - val markerState = com.google.maps.android.compose.rememberMarkerState( - position = LatLng(markerData.latitude, markerData.longitude) - ) - com.google.maps.android.compose.Marker( - state = markerState, - title = markerData.title, - snippet = markerData.snippet - ) - } - } -} - 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 index 2923541ae..6713b74b1 100644 --- 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 @@ -17,90 +17,114 @@ 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.ui.Modifier +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.LatLng -import com.google.maps.android.model.latitude -import com.google.maps.android.model.longitude +import com.google.maps.android.model.zoom +import kotlin.math.floor +import kotlinx.coroutines.launch /** - * Adapts a [MapMarker] to android-maps-utils' [ClusterItem] so it can be fed to the - * multiplatform clustering algorithms. - */ -private class MapMarkerClusterItem( - val marker: MapMarker, -) : ClusterItem { - override val position: LatLng = LatLng(marker.latitude, marker.longitude) - - override val title: String? = marker.title - - override val snippet: String? = marker.snippet - - override val zIndex: Float? = null -} - -/** - * Groups [markers] into clusters for the given [zoom] level using the multiplatform - * clustering algorithm from android-maps-utils. Runs entirely in common code, so Android - * and iOS produce identical clusters. + * 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. * - * Clusters of one item pass the original marker through; larger clusters are represented - * by a marker at the cluster position whose title carries the cluster size. + * @param maxDistanceBetweenClusteredItems the largest distance, in density-independent pixels, + * between items that share a cluster */ -public fun clusterMarkers( - markers: List, +public fun clusterItems( + items: Collection, zoom: Float, - maxDistanceBetweenClusteredItems: Int = 100, -): List { - val algorithm = NonHierarchicalDistanceBasedAlgorithm() + maxDistanceBetweenClusteredItems: Int = DEFAULT_MAX_DISTANCE, +): List> { + if (items.isEmpty()) return emptyList() + val algorithm = NonHierarchicalDistanceBasedAlgorithm() algorithm.maxDistanceBetweenClusteredItems = maxDistanceBetweenClusteredItems - algorithm.addItems(markers.map { MapMarkerClusterItem(it) }) - return algorithm.getClusters(zoom).map { cluster -> - val items = cluster.items - if (items.size == 1) { - items.first().marker - } else { - MapMarker( - latitude = cluster.position.latitude, - longitude = cluster.position.longitude, - title = "${items.size} items", - ) - } - } + algorithm.addItems(items) + return algorithm.getClusters(zoom).toList() } /** - * A [GoogleMap] that clusters [markers] with android-maps-utils' multiplatform clustering - * algorithm before rendering them. Clustering is recomputed when the markers or [zoom] - * change. + * 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 ClusteredGoogleMap( - modifier: Modifier = Modifier, - latitude: Double, - longitude: Double, - zoom: Float = 10f, - mapType: MapType = MapType.NORMAL, - myLocationEnabled: Boolean = false, - scrollGesturesEnabled: Boolean = true, - zoomGesturesEnabled: Boolean = true, - markers: List = emptyList(), - maxDistanceBetweenClusteredItems: Int = 100, +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 clustered = remember(markers, zoom, maxDistanceBetweenClusteredItems) { - clusterMarkers(markers, zoom, maxDistanceBetweenClusteredItems) + 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 + }, + ) + } + } } - GoogleMap( - modifier = modifier, - latitude = latitude, - longitude = longitude, - zoom = zoom, - mapType = mapType, - myLocationEnabled = myLocationEnabled, - scrollGesturesEnabled = scrollGesturesEnabled, - zoomGesturesEnabled = zoomGesturesEnabled, - markers = clustered, - ) } + +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 index 6be844a77..e96e9d4ed 100644 --- 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 @@ -16,37 +16,72 @@ 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 - -public enum class MapType { - NONE, - NORMAL, - SATELLITE, - TERRAIN, - HYBRID -} - -public data class MapMarker( - val latitude: Double, - val longitude: Double, - val title: String? = null, - val snippet: String? = null -) +import com.google.maps.android.model.LatLng /** - * A multiplatform Map Composable that renders Google Maps on Android and iOS. + * 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 expect fun GoogleMap( +public fun GoogleMap( modifier: Modifier = Modifier, - latitude: Double, - longitude: Double, - zoom: Float = 10f, - mapType: MapType = MapType.NORMAL, - myLocationEnabled: Boolean = false, - scrollGesturesEnabled: Boolean = true, - zoomGesturesEnabled: Boolean = true, - markers: List = emptyList() -) + 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/ClusterMarkersTest.kt b/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusterMarkersTest.kt deleted file mode 100644 index 0a9c7be9f..000000000 --- a/maps-compose-multiplatform/src/commonTest/kotlin/com/google/maps/android/compose/multiplatform/ClusterMarkersTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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 kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertSame -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 ClusterMarkersTest { - private val goldenGate = MapMarker(37.8199, -122.4783, title = "Golden Gate") - private val alcatraz = MapMarker(37.8267, -122.4230, title = "Alcatraz") - private val coitTower = MapMarker(37.8024, -122.4058, title = "Coit Tower") - private val london = MapMarker(51.5007, -0.1246, title = "Big Ben") - - @Test - fun noMarkersGiveNoClusters() { - assertTrue(clusterMarkers(emptyList(), zoom = 10f).isEmpty()) - } - - @Test - fun aSingleMarkerIsReturnedUnchanged() { - val result = clusterMarkers(listOf(goldenGate), zoom = 10f) - - assertSame(goldenGate, result.single()) - } - - @Test - fun nearbyMarkersMergeWhenZoomedOut() { - val result = clusterMarkers(listOf(goldenGate, alcatraz, coitTower), zoom = 5f) - - val cluster = result.single() - assertEquals("3 items", cluster.title) - } - - @Test - fun nearbyMarkersSplitWhenZoomedIn() { - val markers = listOf(goldenGate, alcatraz, coitTower) - - val result = clusterMarkers(markers, zoom = 18f) - - assertEquals(markers.toSet(), result.toSet()) - } - - @Test - fun distantMarkersNeverMerge() { - val result = clusterMarkers(listOf(goldenGate, london), zoom = 3f) - - assertEquals(setOf(goldenGate, london), result.toSet()) - } - - @Test - fun aLargerDistanceMergesMoreMarkers() { - val markers = listOf(goldenGate, alcatraz, coitTower) - - val tight = clusterMarkers(markers, zoom = 12f, maxDistanceBetweenClusteredItems = 1) - val loose = clusterMarkers(markers, zoom = 12f, maxDistanceBetweenClusteredItems = 1_000) - - assertEquals(3, tight.size) - assertEquals(1, loose.size) - } - - @Test - fun aClusterIsPlacedAtOneOfItsMarkers() { - val markers = listOf(goldenGate, alcatraz, coitTower) - - val cluster = clusterMarkers(markers, zoom = 5f).single() - - assertTrue(markers.any { it.latitude == cluster.latitude && it.longitude == cluster.longitude }) - } -} 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/GoogleMap.kt b/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt deleted file mode 100644 index 72f7365e3..000000000 --- a/maps-compose-multiplatform/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/GoogleMap.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * 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.ui.Modifier -import androidx.compose.ui.viewinterop.UIKitView -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.readValue -import platform.CoreGraphics.CGRectZero -import cocoapods.GoogleMaps.* - -import platform.UIKit.UIViewController -import androidx.compose.ui.window.ComposeUIViewController -import androidx.compose.foundation.layout.fillMaxSize - -import androidx.compose.ui.viewinterop.UIKitInteropProperties -import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode -import platform.CoreLocation.CLLocationCoordinate2DMake - -@OptIn(ExperimentalForeignApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) -@Composable -public actual fun GoogleMap( - modifier: Modifier, - latitude: Double, - longitude: Double, - zoom: Float, - mapType: MapType, - myLocationEnabled: Boolean, - scrollGesturesEnabled: Boolean, - zoomGesturesEnabled: Boolean, - markers: List -) { - UIKitView( - factory = { - val camera = GMSCameraPosition.cameraWithLatitude(latitude, longitude, zoom) - GMSMapView.mapWithFrame(CGRectZero.readValue(), camera = camera) - }, - modifier = modifier, - properties = UIKitInteropProperties( - interactionMode = UIKitInteropInteractionMode.NonCooperative - ), - update = { mapView -> - val camera = GMSCameraPosition.cameraWithLatitude(latitude, longitude, zoom) - mapView.animateToCameraPosition(camera) - - // Update map type - mapView.mapType = when (mapType) { - MapType.NONE -> kGMSTypeNone - MapType.NORMAL -> kGMSTypeNormal - MapType.SATELLITE -> kGMSTypeSatellite - MapType.TERRAIN -> kGMSTypeTerrain - MapType.HYBRID -> kGMSTypeHybrid - } - - // Update my location - mapView.myLocationEnabled = myLocationEnabled - mapView.settings.myLocationButton = myLocationEnabled - - // Update gestures - mapView.settings.scrollGestures = scrollGesturesEnabled - mapView.settings.zoomGestures = zoomGesturesEnabled - - // Clear old and add new markers - mapView.clear() - markers.forEach { markerData -> - val marker = GMSMarker() - marker.position = CLLocationCoordinate2DMake(markerData.latitude, markerData.longitude) - marker.title = markerData.title - marker.snippet = markerData.snippet - marker.map = mapView - } - } - ) -} - -public fun GoogleMapViewController( - latitude: Double, - longitude: Double, - zoom: Float -): UIViewController { - return GoogleMapViewController( - latitude = latitude, - longitude = longitude, - zoom = zoom, - mapType = MapType.NORMAL, - myLocationEnabled = false, - scrollGesturesEnabled = true, - zoomGesturesEnabled = true, - markers = emptyList() - ) -} - -public fun GoogleMapViewController( - latitude: Double, - longitude: Double, - zoom: Float, - markerLatitude: Double?, - markerLongitude: Double?, - markerTitle: String? -): UIViewController { - val markersList = if (markerLatitude != null && markerLongitude != null) { - listOf(MapMarker(latitude = markerLatitude, longitude = markerLongitude, title = markerTitle)) - } else { - emptyList() - } - return GoogleMapViewController( - latitude = latitude, - longitude = longitude, - zoom = zoom, - mapType = MapType.NORMAL, - myLocationEnabled = false, - scrollGesturesEnabled = true, - zoomGesturesEnabled = true, - markers = markersList - ) -} - -public fun GoogleMapViewController( - latitude: Double, - longitude: Double, - zoom: Float, - mapType: MapType, - myLocationEnabled: Boolean, - scrollGesturesEnabled: Boolean, - zoomGesturesEnabled: Boolean, - markers: List -): UIViewController { - return ComposeUIViewController(configure = { - enforceStrictPlistSanityCheck = false - }) { - GoogleMap( - modifier = Modifier.fillMaxSize(), - latitude = latitude, - longitude = longitude, - zoom = zoom, - mapType = mapType, - myLocationEnabled = myLocationEnabled, - scrollGesturesEnabled = scrollGesturesEnabled, - zoomGesturesEnabled = zoomGesturesEnabled, - markers = markers - ) - } -} - 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 d74e64a7f..8288327c3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -74,4 +74,5 @@ 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 From 425cec8020cc5ca632df76d609f8d16c56f2cb5c Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:02:20 -0600 Subject: [PATCH 12/14] feat(kmp-demo): add shared multiplatform Location Flow demo --- gradle/libs.versions.toml | 1 + iosApp/create_project.rb | 5 +- iosApp/iosApp/Info.plist | 2 + maps-app/src/main/AndroidManifest.xml | 2 + .../demo/PlatformLocationSource.android.kt | 173 ++++++++++++++++++ .../compose/multiplatform/demo/DemoApp.kt | 3 + .../compose/multiplatform/demo/Demos.kt | 97 ++++++++++ .../demo/PlatformLocationSource.kt | 45 +++++ .../demo/PlatformLocationSource.ios.kt | 78 ++++++++ maps-compose-multiplatform/build.gradle.kts | 1 + settings.gradle.kts | 6 +- 11 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 maps-compose-multiplatform-demo/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.android.kt create mode 100644 maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.kt create mode 100644 maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.ios.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d645d93d1..6ebe9cbad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,7 @@ maps-utils-core = { module = "com.google.maps.android:android-maps-utils-core", 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" } diff --git a/iosApp/create_project.rb b/iosApp/create_project.rb index b116dc228..71a224e0f 100644 --- a/iosApp/create_project.rb +++ b/iosApp/create_project.rb @@ -45,7 +45,10 @@ API_KEY="YOUR_API_KEY" if [ -f "$SECRETS_PATH" ]; then - EXTRACTED_KEY=$(grep -E "^MAPS_API_KEY=" "$SECRETS_PATH" | cut -d'=' -f2 | tr -d '"' | tr -d "'") + 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 diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 37d0d4e6c..cd135cb65 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -18,6 +18,8 @@ 1.0 CFBundleVersion 1 + NSLocationWhenInUseUsageDescription + Demonstrates multiplatform LocationSource Flow on Google Maps. LSRequiresIPhoneOS UILaunchScreen diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index d94352990..8f8b2f8e6 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ + + 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) + } + } + + 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 index 625e1f956..80d365b74 100644 --- 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 @@ -73,6 +73,9 @@ public val multiplatformDemos: List = listOf( 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() + }, ) /** 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 index 2345a4320..1d72d4c13 100644 --- 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 @@ -23,6 +23,7 @@ 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 @@ -47,6 +48,13 @@ 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 @@ -272,3 +280,92 @@ internal fun MapClicksDemo() { 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/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/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index b8b5ebc2b..98063e37a 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -72,6 +72,7 @@ kotlin { // 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) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8288327c3..34d57ccb5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,7 +19,11 @@ 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() From 06f7bc45be341c96dc4f4842f54742f6605c5e91 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:06:25 -0600 Subject: [PATCH 13/14] docs(kmp-demo): add TODO referencing android-maps-utils#1818 in PlatformLocationSource.android.kt --- .../multiplatform/demo/PlatformLocationSource.android.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 index d25ddc282..4259ed5a2 100644 --- 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 @@ -75,6 +75,10 @@ internal actual fun rememberPlatformLocationController(): PlatformLocationContro } } + // 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 -> From dfe76f66a10170421cbfd561383d90db021c3e3f Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:27:25 -0600 Subject: [PATCH 14/14] chore(kmp-demo): address review feedback on permission handling, manifest, and trail cap --- maps-app/src/main/AndroidManifest.xml | 2 - .../build.gradle.kts | 5 ++ .../src/androidMain/AndroidManifest.xml | 22 +++++ .../demo/PlatformLocationSource.android.kt | 83 ++++++++++++------- .../compose/multiplatform/demo/Demos.kt | 5 ++ .../demo/PlatformLocationSource.ios.kt | 32 +++++-- 6 files changed, 110 insertions(+), 39 deletions(-) create mode 100644 maps-compose-multiplatform-demo/src/androidMain/AndroidManifest.xml diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index 8f8b2f8e6..d94352990 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -17,8 +17,6 @@ - - + + + + + + + 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 index 4259ed5a2..1cfd9ec2a 100644 --- 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 @@ -25,25 +25,31 @@ 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.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect 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 androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver 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.catch import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.merge -private const val LOCATION_PERMISSION_REQUEST_CODE = 1001 +private val LOCATION_PERMISSIONS = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, +) private fun Context.hasAnyLocationPermission(): Boolean = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || @@ -52,6 +58,10 @@ private fun Context.hasAnyLocationPermission(): Boolean = private fun Context.hasFineLocationPermission(): Boolean = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED +private fun Activity.shouldShowLocationRationale(): Boolean = + shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) || + shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_COARSE_LOCATION) + private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() @@ -64,14 +74,20 @@ internal actual fun rememberPlatformLocationController(): PlatformLocationContro val context = LocalContext.current val appContext = context.applicationContext val activity = remember(context) { context.findActivity() } + val lifecycleOwner = LocalLifecycleOwner.current 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) + // Re-check location permission whenever the host lifecycle resumes (covering both the + // system permission dialog closing and returning from Android App Settings). + DisposableEffect(lifecycleOwner, appContext) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasPermission = appContext.hasAnyLocationPermission() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) } } @@ -112,17 +128,17 @@ internal actual fun rememberPlatformLocationController(): PlatformLocationContro intervalMs = intervalMs, minUpdateDistanceM = minUpdateDistanceM, priority = LocationPriority.HIGH_ACCURACY, - ), + ).catch { if (it !is SecurityException) throw it }, manager.coarseLocationEvents( minTimeMs = intervalMs, minDistanceM = minUpdateDistanceM, - ), + ).catch { if (it !is SecurityException) throw it }, ) } else { manager.coarseLocationEvents( minTimeMs = intervalMs, minDistanceM = minUpdateDistanceM, - ) + ).catch { if (it !is SecurityException) throw it } } stream.collect { send(it) } @@ -140,28 +156,31 @@ internal actual fun rememberPlatformLocationController(): PlatformLocationContro } } - val requestPermission: () -> Unit = remember(activity, appContext, openAppSettings) { + var rationaleBeforeRequest by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + val granted = permissions.values.any { it } || appContext.hasAnyLocationPermission() + hasPermission = granted + if (!granted) { + val rationaleAfterRequest = activity?.shouldShowLocationRationale() ?: false + // If shouldShowRequestPermissionRationale was false before the request and remains + // false after the request was denied, Android suppressed the permission dialog + // because the permission is permanently denied ("Don't ask again" / USER_FIXED). + if (!rationaleBeforeRequest && !rationaleAfterRequest) { + openAppSettings() + } + } + } + + val requestPermission: () -> Unit = remember(activity, permissionLauncher, 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) + rationaleBeforeRequest = activity.shouldShowLocationRationale() + permissionLauncher.launch(LOCATION_PERMISSIONS) } } } 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 index 1d72d4c13..ef2450782 100644 --- 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 @@ -282,6 +282,8 @@ private fun Double.format(): String = (round(this * 10_000) / 10_000).toString() private fun Boolean.onOff(): String = if (this) "on" else "off" +private const val MAX_TRAIL_POINTS = 500 + @Composable internal fun LocationFlowDemo() { val controller = rememberPlatformLocationController() @@ -306,6 +308,9 @@ internal fun LocationFlowDemo() { fixCount++ val target = location.latLng if (trail.lastOrNull() != target) { + if (trail.size >= MAX_TRAIL_POINTS) { + trail.removeAt(0) + } trail += target } markerState.position = target 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 index 86d1aa1be..db969be90 100644 --- 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 @@ -17,20 +17,23 @@ package com.google.maps.android.compose.multiplatform.demo import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect 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.CLLocationManagerDelegateProtocol import platform.CoreLocation.kCLAuthorizationStatusAuthorizedAlways import platform.CoreLocation.kCLAuthorizationStatusAuthorizedWhenInUse +import platform.Foundation.NSNotificationCenter import platform.Foundation.NSURL import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationDidBecomeActiveNotification import platform.UIKit.UIApplicationOpenSettingsURLString +import platform.darwin.NSObject private fun CLAuthorizationStatus.isAuthorized(): Boolean = this == kCLAuthorizationStatusAuthorizedWhenInUse || @@ -41,10 +44,29 @@ internal actual fun rememberPlatformLocationController(): PlatformLocationContro val manager = remember { CLLocationManager() } var hasPermission by remember { mutableStateOf(manager.authorizationStatus.isAuthorized()) } - LaunchedEffect(manager) { - while (true) { + // Observe CoreLocation authorization changes and app foreground activation so returning + // from the system permission dialog or iOS Settings updates hasPermission without polling. + DisposableEffect(manager) { + val authObserver = CLLocationManager() + val authDelegate = object : NSObject(), CLLocationManagerDelegateProtocol { + override fun locationManagerDidChangeAuthorization(manager: CLLocationManager) { + hasPermission = manager.authorizationStatus.isAuthorized() + } + } + authObserver.delegate = authDelegate + val notificationObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIApplicationDidBecomeActiveNotification, + `object` = null, + queue = null, + ) { _ -> hasPermission = manager.authorizationStatus.isAuthorized() - delay(500L) + } + onDispose { + // Keep authDelegate referenced until onDispose because CLLocationManager.delegate is weak. + if (authObserver.delegate === authDelegate) { + authObserver.delegate = null + } + NSNotificationCenter.defaultCenter.removeObserver(notificationObserver) } }