diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/polygons/PolygonsActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/polygons/PolygonsActivity.java index f3f5db2b..b561097c 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/polygons/PolygonsActivity.java +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/polygons/PolygonsActivity.java @@ -124,7 +124,7 @@ public final Camera getInitialCamera() { options.setFillColor(faceFillColor); options.setStrokeColor(faceStrokeColor); options.setStrokeWidth(faceStrokeWidth); - options.setAltitudeMode(AltitudeMode.CLAMP_TO_GROUND); + options.setAltitudeMode(AltitudeMode.ABSOLUTE); options.setGeodesic(false); options.setDrawsOccludedSegments(true); return options; diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/popovers/PopoversActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/popovers/PopoversActivity.java index 451dccb0..441a6466 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/popovers/PopoversActivity.java +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/popovers/PopoversActivity.java @@ -89,6 +89,7 @@ private void initializeMap(@NonNull GoogleMap3D googleMap3D) { if (isInitialized) return; isInitialized = true; + googleMap3D.setCamera(getInitialCamera()); googleMap3D.setMapMode(Map3DMode.SATELLITE); setupPopover(googleMap3D); } diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt index f70a11ee..754ddebf 100644 --- a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/MarkersVisualTest.kt @@ -26,6 +26,9 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +/** + * Visual test for Markers sample in Compose app. + */ @RunWith(AndroidJUnit4::class) class MarkersVisualTest : BaseVisualTest() { @@ -50,10 +53,11 @@ class MarkersVisualTest : BaseVisualTest() { // Define the verification prompt for Gemini val prompt = """ Please act as a UI tester and analyze this screenshot. - 1. Confirm that a 3D map view is visible. - 2. Confirm that an alien icon or marker is visible on top of the prominent rock formation (Devils Tower). + 1. Confirm that a 3D satellite map view of New York City (Manhattan) is visible. + 2. Confirm that a Giant Ape/Gorilla marker icon is visible floating near the Empire State Building. + 3. Confirm that custom red and/or yellow pins are visible in the vicinity. - If the map is visible and the alien marker is seen, reply with "PASSED". + If the map is visible and the giant ape marker and custom pins are seen, reply with "PASSED". Otherwise, report what you see. """.trimIndent() diff --git a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt index d4587ae7..a1358e71 100644 --- a/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt +++ b/Maps3DSamples/ComposeDemos/app/src/androidTest/java/com/example/composedemos/PopoversVisualTest.kt @@ -22,10 +22,15 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.Until import com.example.composedemos.popovers.PopoversActivity import kotlinx.coroutines.runBlocking +import org.json.JSONObject import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith +/** + * Visual test for Popovers sample in Compose app. + */ @RunWith(AndroidJUnit4::class) class PopoversVisualTest : BaseVisualTest() { @@ -50,7 +55,7 @@ class PopoversVisualTest : BaseVisualTest() { // Define the prompt for Gemini to find coordinates val promptFind = """ Analyze this screenshot of a 3D map. - You should see a marker or label with the text "Click me for Popover". + You should see a marker or label with the text "Golden Gate Bridge". Find that marker or label. Return its center coordinates as a JSON object: {"x": , "y": } where x and y are normalized coordinates between 0.0 and 1.0 (0.0 is top/left, 1.0 is bottom/right). Return ONLY the JSON object, nothing else. @@ -62,8 +67,10 @@ class PopoversVisualTest : BaseVisualTest() { // Parse JSON and click try { - val jsonStr = geminiResponse?.substringAfter("{")?.substringBeforeLast("}")?.let { "{$it}" } ?: "" - val json = org.json.JSONObject(jsonStr) + val jsonStr = + geminiResponse?.substringAfter("{")?.substringBeforeLast("}")?.let { "{$it}" } + ?: "" + val json = JSONObject(jsonStr) val x = json.getDouble("x") val y = json.getDouble("y") @@ -73,15 +80,17 @@ class PopoversVisualTest : BaseVisualTest() { println("Clicking at ($clickX, $clickY) based on Gemini response") uiDevice.click(clickX, clickY) } catch (e: Exception) { - org.junit.Assert.fail("Failed to parse coordinates from Gemini response: $geminiResponse. Error: ${e.message}") + fail( + "Failed to parse coordinates from Gemini response: $geminiResponse. Error: ${e.message}", + ) } // Wait for the popover text to appear val textFound = uiDevice.wait( - Until.hasObject(By.text("This is a Popover anchored to a marker!")), - 10000, + Until.hasObject(By.text("The Golden Gate Bridge")), + 15000, ) - assertTrue("Popover text not found", textFound) + assertTrue("Popover text not found", textFound == true) // Capture a screenshot for visual confirmation captureScreenshot("popovers_screenshot.png") diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt index 4b24b679..6d21c639 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt @@ -16,38 +16,76 @@ package com.example.composedemos.cameracontrols +import android.content.Context import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.example.composedemos.cameracontrols.DataModel.EMPIRE_STATE_BUILDING_LATITUDE +import com.example.composedemos.cameracontrols.DataModel.EMPIRE_STATE_BUILDING_LONGITUDE +import com.example.composedemos.cameracontrols.DataModel.nycCameraRestriction +import com.example.composedemos.cameracontrols.DataModel.nycPolygonConfigs +import com.example.maps3d.common.copy +import com.example.maps3d.common.toCompassDirection +import com.example.maps3d.common.toValidCamera +import com.example.maps3d.common.wrapIn +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyAroundOptions +import com.google.android.gms.maps3d.model.flyToOptions import com.google.android.gms.maps3d.model.latLngAltitude import com.google.maps.android.compose3d.GoogleMap3D +import kotlinx.coroutines.delay +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import com.example.maps3dcommon.R as CommonR class CameraControlsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -56,8 +94,9 @@ class CameraControlsActivity : ComponentActivity() { // Hide system tray (immersive mode) val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE setContent { MaterialTheme { @@ -74,25 +113,49 @@ class CameraControlsActivity : ComponentActivity() { @Composable fun CameraControlsScreen() { + val context = LocalContext.current var isMapSteady by remember { mutableStateOf(false) } + var hasTriggeredInitialFlyTo by remember { mutableStateOf(false) } + var googleMap3D by remember { mutableStateOf(null) } - // Hoisted camera states - var heading by remember { mutableStateOf(153.2f) } - var tilt by remember { mutableStateOf(75.0f) } - var range by remember { mutableStateOf(584.2f) } - - // Recreate camera object when state changes - val dynamicCamera = remember(heading, tilt, range) { + val initialCamera = remember { camera { center = latLngAltitude { - latitude = 47.620527586075134 - longitude = -122.34935779313246 - altitude = 155.2680153221576 + latitude = 40.7128 + longitude = -74.0060 + altitude = 150.0 } - this.heading = heading.toDouble() - this.tilt = tilt.toDouble() - this.range = range.toDouble() - roll = 0.0 + heading = 252.7 + tilt = 79.0 + range = 1500.0 + } + } + + var showRestriction by remember { mutableStateOf(false) } + var isCameraRestricted by remember { mutableStateOf(false) } + var selectedMapMode by remember { mutableIntStateOf(Map3DMode.SATELLITE) } + var rollValue by remember { mutableFloatStateOf(0.0f) } + var telemetryCamera by remember { mutableStateOf(initialCamera) } + + val cameraStateText = remember(context, telemetryCamera) { + formatCameraState(context, telemetryCamera) + } + + val polygons = remember(showRestriction) { + if (showRestriction) nycPolygonConfigs else emptyList() + } + + val cameraRestriction = remember(isCameraRestricted) { + if (isCameraRestricted) nycCameraRestriction else null + } + + // Automatically fly to the Empire State Building 2 seconds after the map first becomes steady + LaunchedEffect(isMapSteady, googleMap3D) { + val map = googleMap3D + if (isMapSteady && map != null && !hasTriggeredInitialFlyTo) { + delay(2000.milliseconds) + hasTriggeredInitialFlyTo = true + flyToEmpireStateBuilding(map) } } @@ -101,16 +164,30 @@ fun CameraControlsScreen() { .fillMaxSize() .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, ) { - // 1. Map fills the entire screen + // 1. Map fills the screen above/behind the controls GoogleMap3D( - camera = dynamicCamera, + camera = initialCamera, + polygons = polygons, + cameraRestriction = cameraRestriction, + mapMode = selectedMapMode, modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + googleMap3D = map + map.getCamera()?.let { cam -> + telemetryCamera = cam + rollValue = (cam.roll ?: 0.0).toFloat().wrapIn(-180f..180f) + } + }, onMapSteady = { isMapSteady = true }, + onCameraChanged = { cameraPosition -> + telemetryCamera = cameraPosition + rollValue = (cameraPosition.roll ?: 0.0).toFloat().wrapIn(-180f..180f) + }, ) - // 2. Custom Translucent Top Bar + // 2. Translucent Top Bar Box( modifier = Modifier .fillMaxWidth() @@ -119,7 +196,7 @@ fun CameraControlsScreen() { .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Camera Controls", + text = stringResource(CommonR.string.feature_title_camera_controls), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -129,35 +206,264 @@ fun CameraControlsScreen() { Card( modifier = Modifier .align(Alignment.BottomCenter) + .navigationBarsPadding() .padding(16.dp) - .padding(bottom = 32.dp) .fillMaxWidth(), ) { Column(modifier = Modifier.padding(16.dp)) { - Text(text = "Heading: ${heading.toInt()}°", style = MaterialTheme.typography.bodyMedium) - Slider( - value = heading, - onValueChange = { heading = it }, - valueRange = 0f..360f, - modifier = Modifier.semantics { contentDescription = "Heading Slider" }, + // Row 1: Fly to ESB & Fly around center + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { flyToEmpireStateBuilding(googleMap3D) }, + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(CommonR.string.fly_to), + style = MaterialTheme.typography.bodySmall, + ) + } + + OutlinedButton( + onClick = { flyAroundCurrentCenter(googleMap3D) }, + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(CommonR.string.fly_around), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + // Row 2: Show/Hide restriction cube & Restrict/Remove camera restriction + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { showRestriction = !showRestriction }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (showRestriction) { + MaterialTheme.colorScheme.secondaryContainer + } else { + Color.Transparent + }, + contentColor = if (showRestriction) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.primary + }, + ), + ) { + Text( + text = stringResource( + if (showRestriction) { + CommonR.string.camera_hide_restriction + } else { + CommonR.string.camera_show_restriction + }, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + + OutlinedButton( + onClick = { isCameraRestricted = !isCameraRestricted }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (isCameraRestricted) { + MaterialTheme.colorScheme.secondaryContainer + } else { + Color.Transparent + }, + contentColor = if (isCameraRestricted) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.primary + }, + ), + ) { + Text( + text = stringResource( + if (isCameraRestricted) { + CommonR.string.camera_remove_restriction + } else { + CommonR.string.camera_activate_restriction + }, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + // Row 3: Map Mode RadioGroup (Hybrid / Satellite) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .selectableGroup(), + verticalAlignment = Alignment.CenterVertically, + ) { + val modes = listOf( + Map3DMode.HYBRID to stringResource(CommonR.string.map_mode_hybrid), + Map3DMode.SATELLITE to stringResource(CommonR.string.map_mode_satellite), + ) + modes.forEachIndexed { index, (modeValue, modeLabel) -> + if (index > 0) { + Spacer(modifier = Modifier.width(8.dp)) + } + Row( + modifier = Modifier + .weight(1f) + .selectable( + selected = (selectedMapMode == modeValue), + onClick = { selectedMapMode = modeValue }, + role = Role.RadioButton, + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = (selectedMapMode == modeValue), + onClick = null, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = modeLabel, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Row 4: Roll Slider + Reset Roll Button + Text( + text = stringResource(CommonR.string.camera_roll_label_dynamic, rollValue), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 8.dp), ) - Text(text = "Tilt: ${tilt.toInt()}°", style = MaterialTheme.typography.bodyMedium) - Slider( - value = tilt, - onValueChange = { tilt = it }, - valueRange = 0f..90f, - modifier = Modifier.semantics { contentDescription = "Tilt Slider" }, + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Slider( + value = rollValue, + onValueChange = { value -> + val steppedValue = value.roundToInt().toFloat() + rollValue = steppedValue + updateMapRoll(googleMap3D, steppedValue.toDouble()) + }, + valueRange = -360f..360f, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Roll Slider" }, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FilledIconButton( + onClick = { + val resetValue = 0.0f + rollValue = resetValue + updateMapRoll(googleMap3D, resetValue.toDouble()) + }, + ) { + Icon( + painter = painterResource(id = CommonR.drawable.outline_recenter_24), + contentDescription = stringResource( + CommonR.string.reset_roll_content_description, + ), + ) + } + } + + // Row 5: Camera State Telemetry + Text( + text = stringResource(CommonR.string.camera_state_label), + style = MaterialTheme.typography.titleSmall, ) - Text(text = "Range: ${range.toInt()}m", style = MaterialTheme.typography.bodyMedium) - Slider( - value = range, - onValueChange = { range = it }, - valueRange = 100f..5000f, - modifier = Modifier.semantics { contentDescription = "Range Slider" }, + Text( + text = cameraStateText, + style = MaterialTheme.typography.bodyMedium, ) } } } } + +/** + * Initiates a camera flight animation to the Empire State Building. + */ +private fun flyToEmpireStateBuilding(googleMap3D: GoogleMap3D?) { + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = camera { + center = latLngAltitude { + latitude = EMPIRE_STATE_BUILDING_LATITUDE + longitude = EMPIRE_STATE_BUILDING_LONGITUDE + altitude = 212.0 + } + heading = 34.0 + tilt = 67.0 + range = 750.0 + roll = 0.0 + } + durationInMillis = 2_000 + }, + ) +} + +/** + * Initiates a camera fly-around animation around the current camera's center. + */ +private fun flyAroundCurrentCenter(googleMap3D: GoogleMap3D?) { + val camera = googleMap3D?.getCamera()?.toValidCamera() ?: return + + googleMap3D.flyCameraAround( + flyAroundOptions { + center = camera + durationInMillis = 5_000 + rounds = 1.0 + }, + ) +} + +/** + * Updates the map camera roll angle while preserving current center, heading, tilt, and range. + */ +private fun updateMapRoll(googleMap3D: GoogleMap3D?, newRoll: Double) { + googleMap3D?.getCamera()?.let { currentCamera -> + googleMap3D.setCamera( + currentCamera.toValidCamera().copy(roll = newRoll), + ) + } +} + +/** + * Formats the [Camera] state string matching the Kotlin View implementation. + */ +private fun formatCameraState(context: Context, camera: Camera): String { + val nbsp = "\u00A0" + val heading = camera.heading ?: 0.0 + val compassString = heading.toCompassDirection() + + return buildString { + append(context.getString(CommonR.string.cam_lat_label, camera.center.latitude)) + append(", ") + append(context.getString(CommonR.string.cam_lng_label, camera.center.longitude)) + append(", ") + append(context.getString(CommonR.string.cam_alt_label, camera.center.altitude)) + append(",\n") + append(context.getString(CommonR.string.cam_hdg_label, heading)) + append("$nbsp($compassString)") + append(", ") + append(context.getString(CommonR.string.cam_tlt_label, camera.tilt ?: 0.0)) + append(", ") + append(context.getString(CommonR.string.cam_rng_label, camera.range ?: 0.0)) + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/DataModel.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/DataModel.kt new file mode 100644 index 00000000..f25811a2 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/DataModel.kt @@ -0,0 +1,166 @@ +/* + * 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.example.composedemos.cameracontrols + +import android.graphics.Color +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.cameraRestriction +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.gms.maps3d.model.latLngBounds +import com.google.maps.android.compose3d.PolygonConfig + +/** + * DataModel is an object that holds constants and calculated values related to the New York City + * area, specifically for camera restrictions and polygon definitions. + */ +object DataModel { + const val EMPIRE_STATE_BUILDING_LATITUDE = 40.748233 + const val EMPIRE_STATE_BUILDING_LONGITUDE = -73.985663 + + private const val NYC_SOUTH_WEST_LAT = 40.68563088976172 + private const val NYC_SOUTH_WEST_LNG = -74.05030430240065 + private const val NYC_NORTH_EAST_LAT = 40.85649214337128 + private const val NYC_NORTH_EAST_LNG = -73.80240973771173 + private const val MAX_ALTITUDE_NYC_METERS = 10000.0 + private const val MIN_ALTITUDE_NYC_METERS = 500.0 + + private val nycBounds = latLngBounds { + northEastLat = NYC_NORTH_EAST_LAT + northEastLng = NYC_NORTH_EAST_LNG + southWestLat = NYC_SOUTH_WEST_LAT + southWestLng = NYC_SOUTH_WEST_LNG + } + + /** + * Defines the camera restrictions for the NYC area. + * + * This restriction enforces that the camera must: + * - Maintain an altitude between [MIN_ALTITUDE_NYC_METERS] and [MAX_ALTITUDE_NYC_METERS]. + * - Stay within the geographic bounds defined by [nycBounds]. + */ + val nycCameraRestriction = cameraRestriction { + minAltitude = MIN_ALTITUDE_NYC_METERS + maxAltitude = MAX_ALTITUDE_NYC_METERS + bounds = nycBounds + minHeading = 0.0 + maxHeading = 360.0 + minTilt = 0.0 + maxTilt = 90.0 + } + + /** + * Defines the base face of a polygon representing the ground area of New York City. + * This face is defined by four LatLngAltitude points that form a rectangle. + * All points share the same minimum altitude. + */ + private val baseFace = listOf( + latLngAltitude { + latitude = NYC_SOUTH_WEST_LAT + longitude = NYC_SOUTH_WEST_LNG + altitude = MIN_ALTITUDE_NYC_METERS + }, + latLngAltitude { + latitude = NYC_SOUTH_WEST_LAT + longitude = NYC_NORTH_EAST_LNG + altitude = MIN_ALTITUDE_NYC_METERS + }, + latLngAltitude { + latitude = NYC_NORTH_EAST_LAT + longitude = NYC_NORTH_EAST_LNG + altitude = MIN_ALTITUDE_NYC_METERS + }, + latLngAltitude { + latitude = NYC_NORTH_EAST_LAT + longitude = NYC_SOUTH_WEST_LNG + altitude = MIN_ALTITUDE_NYC_METERS + }, + ) + + private val extrudedNyc = extrudePolygon(baseFace, MAX_ALTITUDE_NYC_METERS) + + // Define style for the cube faces (semi-transparent blue fill with solid darker blue stroke) + private val faceFillColor = Color.argb(70, 0, 120, 255) + private val faceStrokeColor = Color.rgb(0, 80, 200) + private const val FACE_STROKE_WIDTH = 3.0f + + val nycPolygonConfigs: List = extrudedNyc.mapIndexed { index, facePoints -> + PolygonConfig( + key = "nyc_restriction_face_$index", + path = facePoints, + fillColor = faceFillColor, + strokeColor = faceStrokeColor, + strokeWidth = FACE_STROKE_WIDTH, + altitudeMode = AltitudeMode.ABSOLUTE, + geodesic = false, + drawsOccludedSegments = true, + ) + } +} + +/** + * Extrudes a flat polygon (defined by basePoints, all at the same altitude) + * upwards by a given extrusionHeight to form a 3D prism. + * + * @param basePoints A list of LatLngAltitude points defining the base polygon. + * All points must have the same altitude. + * The order of points defines the winding (e.g., clockwise when viewed from above). + * @param extrusionHeight The height to extrude the polygon upwards. Must be positive. + * @return A list of faces, where each face is a list of LatLngAltitude vertices + * defining that face. Returns an empty list if input is invalid. + */ +fun extrudePolygon( + basePoints: List, + extrusionHeight: Double, +): List> { + if (basePoints.size < 3 || extrusionHeight <= 0) { + return emptyList() + } + + val baseAltitude = basePoints.first().altitude + + // 1. Create points for the top face + val topPoints = basePoints.map { basePoint -> + latLngAltitude { + latitude = basePoint.latitude + longitude = basePoint.longitude + altitude = baseAltitude + extrusionHeight + } + } + + val faces = mutableListOf>() + + // 2. Add bottom face + faces.add(basePoints.toList()) + + // 3. Add top face + faces.add(topPoints.toList().reversed()) + + // 4. Add side wall faces + for (i in basePoints.indices) { + val p1Base = basePoints[i] + val p2Base = basePoints[(i + 1) % basePoints.size] + + val p1Top = topPoints[i] + val p2Top = topPoints[(i + 1) % topPoints.size] + + val sideFace = listOf(p1Base, p2Base, p2Top, p1Top) + faces.add(sideFace) + } + + return faces +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt index f6d18e8a..5479e540 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/MarkersActivity.kt @@ -17,46 +17,91 @@ package com.example.composedemos.markers import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import com.example.composedemos.R +import com.example.composedemos.markers.data.Monster +import com.example.composedemos.markers.data.MonsterParser +import com.example.maps3d.common.toCameraString +import com.example.maps3d.common.toValidCamera +import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Camera import com.google.android.gms.maps3d.model.CollisionBehavior +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions import com.google.android.gms.maps3d.model.ImageView import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyAroundOptions +import com.google.android.gms.maps3d.model.flyToOptions import com.google.android.gms.maps3d.model.latLngAltitude import com.google.maps.android.compose3d.GlyphConfig import com.google.maps.android.compose3d.GoogleMap3D import com.google.maps.android.compose3d.MarkerConfig import com.google.maps.android.compose3d.PinConfig import com.google.maps.android.compose3d.PopoverConfig +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.seconds +import android.graphics.Color as AndroidColor +import com.example.maps3dcommon.R as CommonR + +private const val TAG = "MarkersActivity" class MarkersActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -65,8 +110,9 @@ class MarkersActivity : ComponentActivity() { // Hide system tray (immersive mode) val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE setContent { MaterialTheme { @@ -81,120 +127,339 @@ class MarkersActivity : ComponentActivity() { } } +/** + * Jetpack Compose implementation of the Markers sample demonstrating: + * - Four altitude modes in Berlin (`ABSOLUTE`, `RELATIVE_TO_GROUND`, `CLAMP_TO_GROUND`, `RELATIVE_TO_MESH`) + * - Empire State Building custom image marker with popover and custom color/text glyph pins in NYC + * - Monster markers parsed from `monsters.json` with interactive popovers, random monster flight + * (with long-press monster picker menu), and an automated Monster Tour. + */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun MarkersScreen() { val context = LocalContext.current + val scope = rememberCoroutineScope() + var isMapSteady by remember { mutableStateOf(false) } + val mapSteadyFlow = remember { MutableStateFlow(false) } + var googleMap3D by remember { mutableStateOf(null) } var popovers by remember { mutableStateOf(emptyList()) } + var isTouring by remember { mutableStateOf(false) } + var showMonsterMenu by remember { mutableStateOf(false) } - // Camera centered on Devils Tower - val devilsTowerCamera = remember { + val berlinCamera = remember { camera { center = latLngAltitude { - latitude = 44.589994 - longitude = -104.715326 - altitude = 1508.9 + latitude = 52.51974795 + longitude = 13.40715553 + altitude = 150.0 } - heading = 1.0 - tilt = 75.0 - range = 1635.0 - roll = 0.0 + heading = 252.7 + tilt = 79.0 + range = 1500.0 } } - // 1. Default Image-styled Marker with REQUIRED collision behavior - val alienMarker = remember { - MarkerConfig( - key = "alien", - position = latLngAltitude { - latitude = 44.590548 - longitude = -104.715177 - altitude = 10.0 - }, - altitudeMode = AltitudeMode.RELATIVE_TO_MESH, - styleView = ImageView(R.drawable.alien), - label = "Devil's Tower Alien", - isExtruded = true, - isDrawnWhenOccluded = true, - collisionBehavior = CollisionBehavior.REQUIRED, - onClick = { - popovers = listOf( - PopoverConfig( - key = "alien_popover", - positionAnchorKey = "alien", - autoPanEnabled = false, - autoCloseEnabled = true, - content = { - Surface( - color = Color.White, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.padding(8.dp), - ) { - Text( - text = "They didn't just come to sculpt mashed potatoes. 👽", - modifier = Modifier.padding(16.dp), - color = Color.Black, + val nycCamera = remember { + camera { + center = latLngAltitude { + latitude = 40.748425 + longitude = -73.985590 + altitude = 348.7 + } + heading = 22.0 + tilt = 80.0 + range = 1518.0 + } + } + + val parsedMonsters = remember(context) { + try { + val jsonString = context.assets.open("monsters.json").bufferedReader().use { + it.readText() + } + MonsterParser.parse(jsonString).filter { getMonsterDrawableId(it.drawable) != 0 } + } catch (e: Exception) { + Log.e(TAG, "Error loading monsters.json", e) + emptyList() + } + } + + val monsterCameras = remember(parsedMonsters) { + parsedMonsters.map { monster -> + camera { + center = latLngAltitude { + latitude = monster.latitude + longitude = monster.longitude + altitude = monster.altitude + } + heading = monster.heading + tilt = monster.tilt + range = monster.range + } + } + } + + fun showToast(label: String) { + scope.launch { + Toast.makeText(context, "Clicked on marker: $label", Toast.LENGTH_SHORT).show() + } + } + + fun showBlurbPopover(anchorKey: String, altitudeMode: Int, blurbResId: Int) { + if (blurbResId == 0) return + scope.launch { + popovers = listOf( + createPopoverConfig( + key = "popover_$anchorKey", + anchorKey = anchorKey, + altitudeMode = altitudeMode, + blurbResId = blurbResId, + ), + ) + } + } + + val allMarkers = remember(parsedMonsters) { + buildList { + // Marker 1: Absolute Altitude (Berlin) + add( + MarkerConfig( + key = "marker_one", + position = latLngAltitude { + latitude = 52.519605780912585 + longitude = 13.406867190588198 + altitude = 150.0 + }, + label = "Absolute (150m)", + altitudeMode = AltitudeMode.ABSOLUTE, + isExtruded = true, + isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, + onClick = { showToast("Absolute (150m)") }, + ), + ) + + // Marker 2: Relative to Ground (Berlin) + add( + MarkerConfig( + key = "relative_to_ground", + position = latLngAltitude { + latitude = 52.519882191069016 + longitude = 13.407410777254293 + altitude = 50.0 + }, + label = "Relative to Ground (50m)", + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + isExtruded = true, + isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, + onClick = { showToast("Relative to Ground (50m)") }, + ), + ) + + // Marker 3: Clamped to Ground (Berlin) + add( + MarkerConfig( + key = "clamped_to_ground", + position = latLngAltitude { + latitude = 52.52027645136134 + longitude = 13.408271658592406 + altitude = 0.0 + }, + label = "Clamped to Ground", + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + isExtruded = true, + isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.REQUIRED, + onClick = { showToast("Clamped to Ground") }, + ), + ) + + // Marker 4: Relative to Mesh (Berlin) + add( + MarkerConfig( + key = "relative_to_mesh", + position = latLngAltitude { + latitude = 52.520835071144226 + longitude = 13.409426847943774 + altitude = 10.0 + }, + label = "Relative to Mesh (10m)", + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + isExtruded = true, + isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.REQUIRED, + onClick = { showToast("Relative to Mesh (10m)") }, + ), + ) + + // Marker 8: Empire State Building Ape (NYC) + add( + MarkerConfig( + key = "esb_ape", + position = latLngAltitude { + latitude = 40.7484 + longitude = -73.9857 + altitude = 100.0 + }, + zIndex = 1, + label = "Giant Ape / Empire State Building", + isExtruded = true, + isDrawnWhenOccluded = true, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + styleView = ImageView(CommonR.drawable.ook), + onClick = { + showBlurbPopover( + anchorKey = "esb_ape", + altitudeMode = AltitudeMode.ABSOLUTE, + blurbResId = CommonR.string.monster_ape_blurb, + ) + }, + ), + ) + + // Marker 9: Custom Color Pin near ESB (NYC) + add( + MarkerConfig( + key = "custom_color_pin", + position = latLngAltitude { + latitude = 40.7486 + longitude = -73.9848 + altitude = 600.0 + }, + isExtruded = true, + isDrawnWhenOccluded = true, + label = "Custom Color Pin", + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + pinConfig = PinConfig( + backgroundColor = AndroidColor.RED, + borderColor = AndroidColor.WHITE, + glyph = GlyphConfig.Color(AndroidColor.CYAN), + ), + onClick = { showToast("Custom Color Pin") }, + ), + ) + + // Marker 10: Custom Text Pin near ESB (NYC) + add( + MarkerConfig( + key = "custom_text_pin", + position = latLngAltitude { + latitude = 40.7482 + longitude = -73.9862 + altitude = 600.0 + }, + isExtruded = true, + isDrawnWhenOccluded = true, + label = "Custom Text Pin", + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + pinConfig = PinConfig( + backgroundColor = AndroidColor.YELLOW, + borderColor = AndroidColor.BLUE, + glyph = GlyphConfig.Text("NYC\n 🍎 ", AndroidColor.RED), + ), + onClick = { showToast("Custom Text Pin") }, + ), + ) + + // Monsters from monsters.json + parsedMonsters.forEach { monster -> + val drawableId = getMonsterDrawableId(monster.drawable) + val anchorKey = "monster_${monster.id}" + val blurbResId = getMonsterBlurbResId(monster.id) + add( + MarkerConfig( + key = anchorKey, + position = latLngAltitude { + latitude = monster.markerLatitude + longitude = monster.markerLongitude + altitude = monster.markerAltitude + }, + label = monster.label, + isExtruded = true, + isDrawnWhenOccluded = true, + altitudeMode = monster.altitudeMode, + styleView = ImageView(drawableId), + onClick = { + if (blurbResId != 0) { + showBlurbPopover( + anchorKey = anchorKey, + altitudeMode = monster.altitudeMode, + blurbResId = blurbResId, ) + } else { + showToast(monster.label) } }, ), ) - }, - ) + } + } } - // 2. Custom Styled Red Pin with Cyan Icon Glyph and OPTIONAL collision behavior - val redPinMarker = remember { - MarkerConfig( - key = "styled_red_pin", - position = latLngAltitude { - latitude = 44.5902 - longitude = -104.7148 - altitude = 50.0 - }, - altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, - label = "Custom Color Pin", - isExtruded = true, - isDrawnWhenOccluded = true, - collisionBehavior = CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, - pinConfig = PinConfig( - backgroundColor = android.graphics.Color.RED, - borderColor = android.graphics.Color.WHITE, - glyph = GlyphConfig.Color(android.graphics.Color.CYAN), - ), - onClick = { - Toast.makeText(context, "Clicked Styled Red Pin!", Toast.LENGTH_SHORT).show() - }, - ) + fun stopMonsterTour() { + if (isTouring) { + isTouring = false + googleMap3D?.stopCameraAnimation() + googleMap3D?.setCameraAnimationEndListener(null) + } } - // 3. Custom Styled Yellow Pin with Text Glyph and OPTIONAL collision behavior - val yellowTextPinMarker = remember { - MarkerConfig( - key = "styled_text_pin", - position = latLngAltitude { - latitude = 44.5895 - longitude = -104.7160 - altitude = 50.0 - }, - altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, - label = "Custom Text Pin", - isExtruded = true, - isDrawnWhenOccluded = true, - collisionBehavior = CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY, - pinConfig = PinConfig( - backgroundColor = android.graphics.Color.YELLOW, - borderColor = android.graphics.Color.BLUE, - glyph = GlyphConfig.Text("WY\n⛰️", android.graphics.Color.RED), - ), - onClick = { - Toast.makeText(context, "Clicked Wyoming Text Pin!", Toast.LENGTH_SHORT).show() - }, - ) - } + // Automated Monster Tour coroutine + LaunchedEffect(isTouring, googleMap3D, parsedMonsters) { + val map = googleMap3D + if (!isTouring || map == null || parsedMonsters.isEmpty()) return@LaunchedEffect + + var index = 0 + while (isActive && isTouring) { + popovers = emptyList() + + val monster: Monster = parsedMonsters[index] + val targetCamera: Camera = monsterCameras[index] + val anchorKey = "monster_${monster.id}" + val blurbResId = getMonsterBlurbResId(monster.id) + + // 1. Fly to the monster + map.awaitCameraAnimation( + flyToOptions { + endCamera = targetCamera + durationInMillis = 4.seconds.inWholeMilliseconds + }, + ) + if (!isActive || !isTouring) break + + // 2. Wait for the 3D mesh building geometry to load (up to 5 seconds) + withTimeoutOrNull(5.seconds) { + mapSteadyFlow.first { it } + } + if (!isActive || !isTouring) break + + // 3. Orbit around the monster + map.awaitCameraAnimation( + flyAroundOptions { + center = targetCamera + durationInMillis = 5.seconds.inWholeMilliseconds + rounds = 1.0 + }, + ) + if (!isActive || !isTouring) break - val allMarkers = remember(alienMarker, redPinMarker, yellowTextPinMarker) { - listOf(alienMarker, redPinMarker, yellowTextPinMarker) + // 4. Show monster blurb popover + if (blurbResId != 0) { + popovers = listOf( + createPopoverConfig( + key = "popover_$anchorKey", + anchorKey = anchorKey, + altitudeMode = monster.altitudeMode, + blurbResId = blurbResId, + ), + ) + } + + delay(4.seconds) + index = (index + 1) % parsedMonsters.size + } } Box( @@ -204,14 +469,20 @@ fun MarkersScreen() { ) { // 1. Map fills the entire screen GoogleMap3D( - camera = devilsTowerCamera, - mapMode = Map3DMode.HYBRID, + camera = nycCamera, + mapMode = Map3DMode.SATELLITE, markers = allMarkers, popovers = popovers, modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + googleMap3D = map + }, onMapSteady = { isMapSteady = true }, + onMapSteadyChange = { steady -> + mapSteadyFlow.value = steady + }, onMapClick = { popovers = emptyList() }, @@ -226,10 +497,282 @@ fun MarkersScreen() { .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Markers", + text = stringResource(CommonR.string.feature_title_markers), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) } + + // 3. Floating Pill Control Bar (matches activity_common_map.xml + MarkersActivity.kt) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .background( + color = Color(0x80FFFFFF), + shape = RoundedCornerShape(32.dp), + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!isTouring) { + // Tour Monsters Button (🗺️) + FilledTonalButton( + onClick = { + if (monsterCameras.isNotEmpty()) { + isTouring = true + } + }, + modifier = Modifier + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) + .alpha(0.85f) + .semantics { + contentDescription = context.getString(CommonR.string.tour_monsters) + }, + ) { + Text(text = "🗺️", fontSize = 20.sp) + } + } else { + // Stop Tour Button + FilledTonalIconButton( + onClick = { stopMonsterTour() }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.stop_24px), + contentDescription = stringResource(CommonR.string.stop_camera), + ) + } + } + + // Fly to Random Monster Button (🎲) with Long-Press Monster Picker Menu + Box { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) + .alpha(0.85f) + .clip(CircleShape) + .combinedClickable( + onClick = { + stopMonsterTour() + if (monsterCameras.isNotEmpty()) { + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = monsterCameras.random() + durationInMillis = 4_000 + }, + ) + } + }, + onLongClick = { + if (parsedMonsters.isNotEmpty()) { + showMonsterMenu = true + } + }, + ) + .semantics { + contentDescription = context.getString( + CommonR.string.content_description_fly_random, + ) + }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp), + ) { + Text(text = "🎲", fontSize = 20.sp) + } + } + + DropdownMenu( + expanded = showMonsterMenu, + onDismissRequest = { showMonsterMenu = false }, + ) { + parsedMonsters.forEachIndexed { index, monster -> + DropdownMenuItem( + text = { Text(monster.label) }, + onClick = { + showMonsterMenu = false + stopMonsterTour() + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = monsterCameras[index] + durationInMillis = 4_000 + }, + ) + }, + ) + } + } + } + + // Fly to Berlin (4 AltitudeMode Markers) + FilledTonalIconButton( + onClick = { + stopMonsterTour() + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = berlinCamera + durationInMillis = 4_000 + }, + ) + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.public_24px), + contentDescription = stringResource( + CommonR.string.content_description_fly_berlin, + ), + ) + } + + // Fly to NYC (Empire State Building Ape & Custom Pins) + FilledTonalIconButton( + onClick = { + stopMonsterTour() + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = nycCamera + durationInMillis = 4_000 + }, + ) + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.location_city_24px), + contentDescription = stringResource( + CommonR.string.content_description_fly_nyc, + ), + ) + } + + // Reset View Button + FilledTonalIconButton( + onClick = { + stopMonsterTour() + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = nycCamera + durationInMillis = 2_000 + }, + ) + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.restart_alt_24px), + contentDescription = stringResource(CommonR.string.reset_view), + ) + } + + // Snapshot Button + FilledTonalIconButton( + onClick = { + googleMap3D?.getCamera()?.let { cam -> + Log.d(TAG, cam.toValidCamera().toCameraString()) + } + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.photo_camera_24px), + contentDescription = stringResource(CommonR.string.snapshot_camera), + ) + } + } + } } } + +private fun createPopoverConfig( + key: String, + anchorKey: String, + altitudeMode: Int, + blurbResId: Int, +): PopoverConfig = PopoverConfig( + key = key, + positionAnchorKey = anchorKey, + altitudeMode = altitudeMode, + autoCloseEnabled = true, + autoPanEnabled = false, + content = { + Surface( + color = Color.White, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = stringResource(blurbResId), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = Color.Black, + ) + } + }, +) + +/** + * Initiates a FlyTo camera animation and suspends until it finishes. + */ +private suspend fun GoogleMap3D.awaitCameraAnimation(options: FlyToOptions) { + suspendCancellableCoroutine { cont -> + setCameraAnimationEndListener { + setCameraAnimationEndListener(null) + if (cont.isActive) cont.resume(Unit) + } + flyCameraTo(options) + } +} + +/** + * Initiates a FlyAround camera animation and suspends until it finishes. + */ +private suspend fun GoogleMap3D.awaitCameraAnimation(options: FlyAroundOptions) { + suspendCancellableCoroutine { cont -> + setCameraAnimationEndListener { + setCameraAnimationEndListener(null) + if (cont.isActive) cont.resume(Unit) + } + flyCameraAround(options) + } +} + +/** + * Helper to map monster IDs to their drawable resources without using reflection. + */ +private fun getMonsterDrawableId(drawableName: String): Int = when (drawableName) { + "alien" -> CommonR.drawable.alien + "bigfoot" -> CommonR.drawable.bigfoot + "frank" -> CommonR.drawable.frank + "godzilla" -> CommonR.drawable.godzilla + "mothra" -> CommonR.drawable.mothra + "mummy" -> CommonR.drawable.mummy + "nessie" -> CommonR.drawable.nessie + "yeti" -> CommonR.drawable.yeti + else -> 0 +} + +/** + * Helper to map monster IDs to their blurb string resources. + */ +private fun getMonsterBlurbResId(monsterId: String): Int = when (monsterId) { + "alien" -> CommonR.string.monster_alien_blurb + "bigfoot" -> CommonR.string.monster_bigfoot_blurb + "frank" -> CommonR.string.monster_frank_blurb + "godzilla" -> CommonR.string.monster_godzilla_blurb + "mothra" -> CommonR.string.monster_mothra_blurb + "mummy" -> CommonR.string.monster_mummy_blurb + "nessie" -> CommonR.string.monster_nessie_blurb + "yeti" -> CommonR.string.monster_yeti_blurb + else -> 0 +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/data/Monster.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/data/Monster.kt new file mode 100644 index 00000000..d48b8dc6 --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/data/Monster.kt @@ -0,0 +1,33 @@ +/* + * 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.example.composedemos.markers.data + +data class Monster( + val id: String, + val label: String, + val latitude: Double, + val longitude: Double, + val altitude: Double, + val heading: Double, + val tilt: Double, + val range: Double, + val markerLatitude: Double, + val markerLongitude: Double, + val markerAltitude: Double, + val drawable: String, + val altitudeMode: Int, +) diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/data/MonsterParser.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/data/MonsterParser.kt new file mode 100644 index 00000000..5d7149cc --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/markers/data/MonsterParser.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. + */ + +package com.example.composedemos.markers.data + +import com.google.android.gms.maps3d.model.AltitudeMode +import org.json.JSONArray + +object MonsterParser { + fun parse(jsonString: String): List { + val monsters = mutableListOf() + val jsonArray = JSONArray(jsonString) + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + + val altitudeModeStr = obj.optString("altitudeMode", "ABSOLUTE") + val parsedAltitudeMode = when (altitudeModeStr) { + "RELATIVE_TO_GROUND" -> AltitudeMode.RELATIVE_TO_GROUND + "CLAMP_TO_GROUND" -> AltitudeMode.CLAMP_TO_GROUND + "RELATIVE_TO_MESH" -> AltitudeMode.RELATIVE_TO_MESH + "ABSOLUTE" -> AltitudeMode.ABSOLUTE + else -> AltitudeMode.ABSOLUTE + } + + monsters.add( + Monster( + id = obj.getString("id"), + label = obj.getString("label"), + latitude = obj.getDouble("latitude"), + longitude = obj.getDouble("longitude"), + altitude = obj.getDouble("altitude"), + heading = obj.getDouble("heading"), + tilt = obj.getDouble("tilt"), + range = obj.getDouble("range"), + markerLatitude = obj.getDouble("markerLatitude"), + markerLongitude = obj.getDouble("markerLongitude"), + markerAltitude = obj.getDouble("markerAltitude"), + drawable = obj.getString("drawable"), + altitudeMode = parsedAltitudeMode, + ), + ) + } + return monsters + } +} diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt index c75df91b..ccd4d3b6 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/polygons/PolygonsActivity.kt @@ -24,11 +24,17 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -38,22 +44,38 @@ 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.draw.alpha import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.example.maps3d.common.miles +import com.example.maps3d.common.toCameraString +import com.example.maps3d.common.toMeters +import com.example.maps3d.common.toValidCamera +import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.model.AltitudeMode import com.google.android.gms.maps3d.model.LatLngAltitude import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyToOptions import com.google.android.gms.maps3d.model.latLngAltitude import com.google.maps.android.compose3d.GoogleMap3D import com.google.maps.android.compose3d.PolygonConfig import kotlinx.coroutines.launch +import androidx.compose.ui.graphics.Color as ComposeColor +import com.example.maps3dcommon.R as CommonR + +private const val TAG = "PolygonsActivity" +private const val DENVER_LATITUDE = 39.748477 +private const val DENVER_LONGITUDE = -104.947575 class PolygonsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -62,8 +84,9 @@ class PolygonsActivity : ComponentActivity() { // Hide system tray (immersive mode) val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE setContent { MaterialTheme { @@ -83,20 +106,23 @@ fun PolygonsScreen() { val context = LocalContext.current val scope = rememberCoroutineScope() var isMapSteady by remember { mutableStateOf(false) } + var googleMap3D by remember { mutableStateOf(null) } + + val museumAltitude = remember { 1.miles.toMeters } - // Define the camera position centered around Denver Zoo + // Define the camera position centered around Denver Zoo & Museum val denverCamera = remember { camera { center = latLngAltitude { - latitude = 39.748477 - longitude = -104.947575 - altitude = 1609.34 // Denver is a mile high! + latitude = DENVER_LATITUDE + longitude = DENVER_LONGITUDE + altitude = museumAltitude } heading = -68.0 tilt = 47.0 roll = 0.0 range = 2251.0 - } + }.toValidCamera() } // Define the outline for the zoo @@ -167,21 +193,24 @@ fun PolygonsScreen() { } } - // Create a polygon config - val polygonConfig = remember { + // Create the zoo polygon config (with hole, clamped to ground) + val zooPolygonConfig = remember(context) { PolygonConfig( key = "denver_zoo", path = zooOutline, innerPaths = listOf(zooHole), - // Translucent yellow fillColor = Color.argb(70, 255, 255, 0), strokeColor = Color.GREEN, strokeWidth = 3f, altitudeMode = AltitudeMode.CLAMP_TO_GROUND, - onClick = { polygon -> - Log.d("PolygonsActivity", "Polygon clicked: $polygon") + onClick = { + Log.d(TAG, "Clicked on zoo polygon") scope.launch { - Toast.makeText(context, "Zoo time!", Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(CommonR.string.polygon_zoo_clicked), + Toast.LENGTH_SHORT, + ).show() } }, ) @@ -202,26 +231,31 @@ fun PolygonsScreen() { latLngAltitude { latitude = lat longitude = lng - altitude = 1609.34 // Denver is a mile high! + altitude = museumAltitude } } } - // Create extruded polygons for the museum - val museumPolygons = remember { + // Create extruded polygons for the museum (AltitudeMode.ABSOLUTE, drawsOccludedSegments = true) + val museumPolygons = remember(context) { extrudePolygon(museumBaseFace, 50.0).mapIndexed { index, outline -> PolygonConfig( key = "museum_face_$index", path = outline, - // Semi-transparent magenta fillColor = Color.argb(70, 255, 0, 255), strokeColor = Color.MAGENTA, strokeWidth = 3f, altitudeMode = AltitudeMode.ABSOLUTE, - onClick = { polygon -> - Log.d("PolygonsActivity", "Museum face clicked: $polygon") + geodesic = false, + drawsOccludedSegments = true, + onClick = { + Log.d(TAG, "Clicked on museum polygon") scope.launch { - Toast.makeText(context, "Museum time!", Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(CommonR.string.polygon_museum_clicked), + Toast.LENGTH_SHORT, + ).show() } }, ) @@ -237,8 +271,17 @@ fun PolygonsScreen() { GoogleMap3D( camera = denverCamera, mapMode = Map3DMode.HYBRID, - polygons = listOf(polygonConfig) + museumPolygons, + polygons = listOf(zooPolygonConfig) + museumPolygons, modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + googleMap3D = map + map.flyCameraTo( + flyToOptions { + endCamera = denverCamera + durationInMillis = 1_000 + }, + ) + }, onMapSteady = { isMapSteady = true }, @@ -253,11 +296,63 @@ fun PolygonsScreen() { .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Polygons", + text = stringResource(CommonR.string.feature_title_polygons), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) } + + // 3. Floating Pill Control Bar (Reset View + Snapshot, matching SampleBaseActivity) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .background( + color = ComposeColor(0x80FFFFFF), + shape = RoundedCornerShape(32.dp), + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledTonalIconButton( + onClick = { + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = denverCamera + durationInMillis = 2_000 + }, + ) + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.restart_alt_24px), + contentDescription = stringResource(CommonR.string.reset_view), + ) + } + + FilledTonalIconButton( + onClick = { + googleMap3D?.getCamera()?.let { cam -> + Log.d(TAG, cam.toValidCamera().toCameraString()) + } + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.photo_camera_24px), + contentDescription = stringResource(CommonR.string.snapshot_camera), + ) + } + } + } } } diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt index 09290daa..0acd09e6 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/popovers/PopoversActivity.kt @@ -16,40 +16,71 @@ package com.example.composedemos.popovers +import android.graphics.Point import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.example.maps3d.common.toCameraString +import com.example.maps3d.common.toValidCamera +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.Popover import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.CollisionBehavior import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyToOptions import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.gms.maps3d.model.popoverShadow +import com.google.android.gms.maps3d.model.popoverStyle import com.google.maps.android.compose3d.GoogleMap3D import com.google.maps.android.compose3d.MarkerConfig import com.google.maps.android.compose3d.PopoverConfig +import kotlinx.coroutines.launch +import android.graphics.Color as AndroidColor +import com.example.maps3dcommon.R as CommonR + +private const val TAG = "PopoversActivity" +private const val CONTENT_LAT = 37.820642 +private const val CONTENT_LNG = -122.478227 +private const val CONTENT_ALT = 0.0 class PopoversActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -58,8 +89,9 @@ class PopoversActivity : ComponentActivity() { // Hide system tray (immersive mode) val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE setContent { MaterialTheme { @@ -76,59 +108,86 @@ class PopoversActivity : ComponentActivity() { @Composable fun PopoversScreen() { - var popovers by remember { mutableStateOf(emptyList()) } + val scope = rememberCoroutineScope() var isMapSteady by remember { mutableStateOf(false) } + var googleMap3D by remember { mutableStateOf(null) } + var popover by remember { mutableStateOf(null) } + var popoverToggleCount by remember { mutableIntStateOf(0) } - // Camera centered on Devils Tower - val devilsTowerCamera = remember { + // Initial camera centered on the Golden Gate Bridge in San Francisco + val initialCamera = remember { camera { center = latLngAltitude { - latitude = 44.589994 - longitude = -104.715326 - altitude = 1508.9 + latitude = CONTENT_LAT + longitude = CONTENT_LNG + altitude = CONTENT_ALT } - heading = 1.0 - tilt = 75.0 - range = 1635.0 - roll = 0.0 + heading = 0.0 + tilt = 45.0 + range = 4075.0 } } - // Sample marker that will trigger the popover - val marker = remember { + val goldenGatePopoverConfig = remember { + PopoverConfig( + key = "golden_gate_popover", + positionAnchorKey = "golden_gate_marker", + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + autoPanEnabled = true, + autoCloseEnabled = true, + anchorOffset = Point(0, 0), + startVisible = false, + popoverStyle = popoverStyle { + padding = 20.0f + backgroundColor = AndroidColor.WHITE + borderRadius = 8.0f + shadow = popoverShadow { + color = AndroidColor.argb(77, 0, 0, 0) + offsetX = 2.0f + offsetY = 4.0f + radius = 4.0f + } + }, + onPopoverCreated = { createdPopover -> + popover = createdPopover + Log.d(TAG, "Popover created") + }, + content = { + GoldenGateInfoContent() + }, + ) + } + + val popovers = remember { listOf(goldenGatePopoverConfig) } + + // Marker on the Golden Gate Bridge that toggles the popover when clicked + val markerInGoldenGate = remember { MarkerConfig( - key = "popover_marker", + key = "golden_gate_marker", position = latLngAltitude { - latitude = 44.59054845363309 - longitude = -104.715177415273 - altitude = 10.0 + latitude = 37.819852 + longitude = -122.478549 + altitude = 0.0 }, - altitudeMode = AltitudeMode.RELATIVE_TO_MESH, - label = "Click me for Popover", + label = "Golden Gate Bridge", + zIndex = 1, isExtruded = true, isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.REQUIRED, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, onClick = { - popovers = listOf( - PopoverConfig( - key = "popover_1", - positionAnchorKey = "popover_marker", - autoPanEnabled = false, - autoCloseEnabled = false, - content = { - Surface( - color = Color.White, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.padding(8.dp), - ) { - Text( - text = "This is a Popover anchored to a marker!", - modifier = Modifier.padding(16.dp), - color = Color.Black, - ) - } - }, - ), - ) + scope.launch { + Log.d(TAG, "Marker clicked") + if (popoverToggleCount > 5) { + popover?.remove() + Log.d(TAG, "Popover removed") + popoverToggleCount = 0 + } else { + Log.d(TAG, "Popover toggled") + popover?.toggle() + popoverToggleCount++ + } + } }, ) } @@ -140,17 +199,17 @@ fun PopoversScreen() { ) { // 1. Map fills the entire screen GoogleMap3D( - camera = devilsTowerCamera, - markers = listOf(marker), + camera = initialCamera, + markers = listOf(markerInGoldenGate), popovers = popovers, - mapMode = Map3DMode.HYBRID, + mapMode = Map3DMode.SATELLITE, modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + googleMap3D = map + }, onMapSteady = { isMapSteady = true }, - onMapClick = { - popovers = emptyList() - }, ) // 2. Custom Translucent Top Bar @@ -162,10 +221,86 @@ fun PopoversScreen() { .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Popovers", + text = stringResource(CommonR.string.feature_title_popovers), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) } + + // 3. Floating Pill Control Bar (Reset View + Snapshot, matching SampleBaseActivity) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .background( + color = Color(0x80FFFFFF), + shape = RoundedCornerShape(32.dp), + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledTonalIconButton( + onClick = { + googleMap3D?.flyCameraTo( + flyToOptions { + endCamera = initialCamera + durationInMillis = 2_000 + }, + ) + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.restart_alt_24px), + contentDescription = stringResource(CommonR.string.reset_view), + ) + } + + FilledTonalIconButton( + onClick = { + googleMap3D?.getCamera()?.let { cam -> + Log.d(TAG, cam.toValidCamera().toCameraString()) + } + }, + modifier = Modifier.alpha(0.85f), + ) { + Icon( + painter = painterResource(id = CommonR.drawable.photo_camera_24px), + contentDescription = stringResource(CommonR.string.snapshot_camera), + ) + } + } + } + } +} + +@Composable +private fun GoldenGateInfoContent() { + Column { + Text( + text = "The Golden Gate Bridge", + fontSize = 18.sp, + color = Color.Black, + ) + Text( + text = "San Francisco, CA", + fontSize = 14.sp, + color = Color.DarkGray, + ) + Text( + text = "The Golden Gate Bridge is a suspension bridge\n" + + " spanning the one-mile-wide strait connecting\n" + + " San Francisco Bay and the Pacific Ocean.\n" + + " The bridge was completed in 1937.", + fontSize = 12.sp, + color = Color.Gray, + ) } } diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt index e73e370f..67ec5f37 100644 --- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt @@ -33,8 +33,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -59,7 +57,9 @@ class MainActivity : ComponentActivity() { @Composable fun CatalogScreen() { val context = LocalContext.current - LazyColumn(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { + LazyColumn(modifier = Modifier + .fillMaxSize() + .safeDrawingPadding()) { item { Text( text = "Maps 3D Compose Samples", @@ -78,7 +78,7 @@ fun CatalogScreen() { } } item { - SampleItem("Camera Controls") { + SampleItem("Camera controls and restrictions") { context.startActivity(Intent(context, CameraControlsActivity::class.java)) } } diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt index 0cc416a2..93ec7136 100644 --- a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt @@ -16,32 +16,48 @@ package com.example.maps3dcomposedemo +import android.graphics.Point import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.sp +import com.google.android.gms.maps3d.Popover import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.CollisionBehavior import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.camera import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.gms.maps3d.model.popoverShadow +import com.google.android.gms.maps3d.model.popoverStyle import com.google.maps.android.compose3d.GoogleMap3D import com.google.maps.android.compose3d.MarkerConfig import com.google.maps.android.compose3d.PopoverConfig +import kotlinx.coroutines.launch +import android.graphics.Color as AndroidColor + +private const val TAG = "PopoversActivity" +private const val CONTENT_LAT = 37.820642 +private const val CONTENT_LNG = -122.478227 +private const val CONTENT_ALT = 0.0 class PopoversActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -62,76 +78,125 @@ class PopoversActivity : ComponentActivity() { @Composable fun PopoversScreen() { - var popovers by remember { mutableStateOf(emptyList()) } + val scope = rememberCoroutineScope() var isMapSteady by remember { mutableStateOf(false) } + var popover by remember { mutableStateOf(null) } + var popoverToggleCount by remember { mutableIntStateOf(0) } - // Camera centered on Devils Tower - val devilsTowerCamera = remember { + val initialCamera = remember { camera { center = latLngAltitude { - latitude = 44.589994 - longitude = -104.715326 - altitude = 1508.9 + latitude = CONTENT_LAT + longitude = CONTENT_LNG + altitude = CONTENT_ALT } - heading = 1.0 - tilt = 75.0 - range = 1635.0 - roll = 0.0 + heading = 0.0 + tilt = 45.0 + range = 4075.0 } } - // Sample marker that will trigger the popover - val marker = remember { + val goldenGatePopoverConfig = remember { + PopoverConfig( + key = "golden_gate_popover", + positionAnchorKey = "golden_gate_marker", + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + autoPanEnabled = true, + autoCloseEnabled = true, + anchorOffset = Point(0, 0), + startVisible = false, + popoverStyle = popoverStyle { + padding = 20.0f + backgroundColor = AndroidColor.WHITE + borderRadius = 8.0f + shadow = popoverShadow { + color = AndroidColor.argb(77, 0, 0, 0) + offsetX = 2.0f + offsetY = 4.0f + radius = 4.0f + } + }, + onPopoverCreated = { createdPopover -> + popover = createdPopover + Log.d(TAG, "Popover created") + }, + content = { + GoldenGateInfoContent() + }, + ) + } + + val popovers = remember { listOf(goldenGatePopoverConfig) } + + val markerInGoldenGate = remember { MarkerConfig( - key = "popover_marker", + key = "golden_gate_marker", position = latLngAltitude { - latitude = 44.59054845363309 - longitude = -104.715177415273 - altitude = 10.0 + latitude = 37.819852 + longitude = -122.478549 + altitude = 0.0 }, - altitudeMode = AltitudeMode.RELATIVE_TO_MESH, - label = "Click me for Popover", + label = "Golden Gate Bridge", + zIndex = 1, isExtruded = true, isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.REQUIRED, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, onClick = { - popovers = listOf( - PopoverConfig( - key = "popover_1", - positionAnchorKey = "popover_marker", - autoPanEnabled = false, - autoCloseEnabled = false, - content = { - Surface( - color = Color.White, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.padding(8.dp), - ) { - Text( - text = "This is a Popover anchored to a marker!", - modifier = Modifier.padding(16.dp), - color = Color.Black, - ) - } - }, - ), - ) + scope.launch { + Log.d(TAG, "Marker clicked") + if (popoverToggleCount > 5) { + popover?.remove() + Log.d(TAG, "Popover removed") + popoverToggleCount = 0 + } else { + Log.d(TAG, "Popover toggled") + popover?.toggle() + popoverToggleCount++ + } + } }, ) } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { GoogleMap3D( - camera = devilsTowerCamera, - markers = listOf(marker), + camera = initialCamera, + markers = listOf(markerInGoldenGate), popovers = popovers, - mapMode = Map3DMode.HYBRID, + mapMode = Map3DMode.SATELLITE, modifier = Modifier.fillMaxSize(), onMapSteady = { isMapSteady = true }, - onMapClick = { - popovers = emptyList() - }, + ) + } +} + +@Composable +private fun GoldenGateInfoContent() { + Column { + Text( + text = "The Golden Gate Bridge", + fontSize = 18.sp, + color = Color.Black, + ) + Text( + text = "San Francisco, CA", + fontSize = 14.sp, + color = Color.DarkGray, + ) + Text( + text = "The Golden Gate Bridge is a suspension bridge\n" + + " spanning the one-mile-wide strait connecting\n" + + " San Francisco Bay and the Pacific Ocean.\n" + + " The bridge was completed in 1937.", + fontSize = 12.sp, + color = Color.Gray, ) } } diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt index 39ad04d3..3e592a86 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt @@ -16,9 +16,11 @@ package com.google.maps.android.compose3d +import android.graphics.Point import androidx.annotation.WorkerThread import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import com.google.android.gms.maps3d.Popover import com.google.android.gms.maps3d.model.AltitudeMode import com.google.android.gms.maps3d.model.CollisionBehavior import com.google.android.gms.maps3d.model.ImageView @@ -27,6 +29,7 @@ import com.google.android.gms.maps3d.model.Marker import com.google.android.gms.maps3d.model.Model import com.google.android.gms.maps3d.model.Polygon import com.google.android.gms.maps3d.model.Polyline +import com.google.android.gms.maps3d.model.PopoverStyle /** * Sealed class representing the glyph (icon/text) inside a pin marker. @@ -97,6 +100,8 @@ data class PolygonConfig( val strokeColor: Int, val strokeWidth: Float, val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val geodesic: Boolean = false, + val drawsOccludedSegments: Boolean = false, val onClick: ((Polygon) -> Unit)? = null, ) @@ -135,4 +140,8 @@ data class PopoverConfig( val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, val autoCloseEnabled: Boolean = true, val autoPanEnabled: Boolean = true, + val anchorOffset: Point? = null, + val popoverStyle: PopoverStyle? = null, + val startVisible: Boolean = true, + val onPopoverCreated: ((Popover) -> Unit)? = null, ) diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt index 4ec383c0..bcd0344c 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt @@ -18,12 +18,17 @@ package com.google.maps.android.compose3d import android.util.Log import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.Map3DInitConfig import com.google.android.gms.maps3d.Map3DView @@ -71,19 +76,19 @@ fun GoogleMap3D( cameraRestriction: CameraRestriction? = null, @Map3DMode mapMode: Int = Map3DMode.SATELLITE, options: Map3DInitConfig = Map3DInitConfig.create( - centerLat = 0.0, - centerLng = 0.0, - centerAlt = 0.0, - heading = 0.0, - tilt = 0.0, - roll = 0.0, - range = 10_000_000.0, // Default range wide view + centerLat = camera.center.latitude, + centerLng = camera.center.longitude, + centerAlt = camera.center.altitude, + heading = camera.heading ?: 0.0, + tilt = camera.tilt ?: 0.0, + roll = camera.roll ?: 0.0, + range = camera.range ?: 10_000_000.0, minHeading = 0.0, maxHeading = 360.0, minTilt = 0.0, maxTilt = 90.0, bounds = null, - mapMode = Map3DMode.SATELLITE, // using the class from com.google.android.gms.maps3d.model.Map3DMode + mapMode = mapMode, mapId = null, minAltitude = 0.0, maxAltitude = 1000000.0, @@ -92,21 +97,58 @@ fun GoogleMap3D( ), onMapReady: (GoogleMap3D) -> Unit = {}, onMapSteady: () -> Unit = {}, + onMapSteadyChange: (Boolean) -> Unit = {}, onMapClick: ((LatLngAltitude) -> Unit)? = null, onPlaceClick: ((String) -> Unit)? = null, onCameraChanged: (Camera) -> Unit = {}, ) { + val hostContext = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val state = remember { Map3DState() } - val hasCalledOnMapReady = remember { mutableStateOf(false) } + val isMapReady = remember { mutableStateOf(false) } val googleMap3DState = remember { mutableStateOf(null) } + val map3DViewState = remember { mutableStateOf(null) } // Use rememberUpdatedState to avoid capturing stale lambdas in the async callback val currentOnMapSteady by rememberUpdatedState(onMapSteady) + val currentOnMapSteadyChange by rememberUpdatedState(onMapSteadyChange) val currentOnCameraChanged by rememberUpdatedState(onCameraChanged) val currentOnMapReady by rememberUpdatedState(onMapReady) val currentOnMapClick by rememberUpdatedState(onMapClick) val currentOnPlaceClick by rememberUpdatedState(onPlaceClick) + DisposableEffect(lifecycleOwner, map3DViewState.value) { + val map3dView = map3DViewState.value ?: return@DisposableEffect onDispose {} + var isViewResumed = false + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + if (!isViewResumed) { + map3dView.onResume() + isViewResumed = true + } + } + + Lifecycle.Event.ON_PAUSE -> { + if (isViewResumed) { + map3dView.onPause() + isViewResumed = false + } + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (isViewResumed) { + map3dView.onPause() + isViewResumed = false + } + } + } + AndroidView( modifier = modifier, factory = { context -> @@ -118,7 +160,22 @@ fun GoogleMap3D( googleMap3DState.value = googleMap3D Map3DRegistry.setInstance(googleMap3D) + googleMap3D.setOnMapReadyListener { + googleMap3D.setOnMapReadyListener(null) + Map3DRegistry.markReady() + isMapReady.value = true + } + + // Ensure readiness triggers even on delayed or reused map instances + map3dView.postDelayed({ + if (!isMapReady.value) { + Map3DRegistry.markReady() + isMapReady.value = true + } + }, 2_000L) + googleMap3D.setOnMapSteadyListener { isSteady -> + currentOnMapSteadyChange(isSteady) if (isSteady) { currentOnMapSteady() } @@ -143,43 +200,34 @@ fun GoogleMap3D( override fun onError(error: Exception): Unit = throw error }) + map3DViewState.value = map3dView map3dView }, - update = { map3dView -> + update = { _ -> val googleMap3D = googleMap3DState.value - if (googleMap3D != null) { - fun applyUpdates() { - if (!hasCalledOnMapReady.value) { - currentOnMapReady(googleMap3D) - hasCalledOnMapReady.value = true - } + if (googleMap3D != null && isMapReady.value) { + // Sync hoisted state with the imperative map instance + state.syncCamera(googleMap3D, camera.toValidCamera()) + state.syncCameraRestriction( + googleMap3D, + cameraRestriction.toValidCameraRestriction(), + ) + state.syncMapMode(googleMap3D, mapMode) - // Sync hoisted state with the imperative map instance - googleMap3D.setCamera(camera.toValidCamera()) - googleMap3D.setCameraRestriction(cameraRestriction.toValidCameraRestriction()) - googleMap3D.setMapMode(mapMode) + state.syncMarkers(googleMap3D, markers) + state.syncPolylines(googleMap3D, polylines) + state.syncPolygons(googleMap3D, polygons) + state.syncModels(googleMap3D, models) + state.syncPopovers(hostContext, googleMap3D, popovers) - state.syncMarkers(googleMap3D, markers) - state.syncPolylines(googleMap3D, polylines) - state.syncPolygons(googleMap3D, polygons) - state.syncModels(googleMap3D, models) - state.syncPopovers(map3dView.context, googleMap3D, popovers) - } - - if (Map3DRegistry.isMapReady) { - // Map was already ready (e.g. reused instance), apply updates immediately - applyUpdates() - } else { - // First time initialization, must wait for listener - googleMap3D.setOnMapReadyListener { - googleMap3D.setOnMapReadyListener(null) // Clear it immediately - Map3DRegistry.markReady() - applyUpdates() - } + if (!state.hasCalledOnMapReady) { + state.hasCalledOnMapReady = true + currentOnMapReady(googleMap3D) } } }, onRelease = { map3dView -> + map3DViewState.value = null state.clear() Map3DRegistry.clearInstance() map3dView.onDestroy() diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt index ce9c2c39..577f2222 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt @@ -17,9 +17,12 @@ package com.google.maps.android.compose3d import android.content.Context +import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.Popover +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.CameraRestriction import com.google.android.gms.maps3d.model.Marker import com.google.android.gms.maps3d.model.Model import com.google.android.gms.maps3d.model.Polygon @@ -40,6 +43,40 @@ class Map3DState { private val polygons = mutableMapOf>() private val models = mutableMapOf>() private val popovers = mutableMapOf>() + private var lastSyncedCamera: Camera? = null + private var lastSyncedCameraRestriction: CameraRestriction? = null + private var lastSyncedMapMode: Int? = null + var hasCalledOnMapReady: Boolean = false + + /** + * Synchronizes the camera on the map only when the target [camera] has changed. + */ + fun syncCamera(map: GoogleMap3D, camera: Camera) { + if (lastSyncedCamera != camera) { + map.setCamera(camera) + lastSyncedCamera = camera + } + } + + /** + * Synchronizes the camera restriction on the map only when [restriction] has changed. + */ + fun syncCameraRestriction(map: GoogleMap3D, restriction: CameraRestriction?) { + if (lastSyncedCameraRestriction != restriction) { + map.setCameraRestriction(restriction) + lastSyncedCameraRestriction = restriction + } + } + + /** + * Synchronizes the map mode only when [mapMode] has changed. + */ + fun syncMapMode(map: GoogleMap3D, mapMode: Int) { + if (lastSyncedMapMode != mapMode) { + map.setMapMode(mapMode) + lastSyncedMapMode = mapMode + } + } /** * Synchronizes the markers on the map with the provided list of configurations. @@ -263,6 +300,10 @@ class Map3DState { val marker = markers[config.positionAnchorKey]?.second ?: return null val composeView = ComposeView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) setContent { config.content() } @@ -275,10 +316,41 @@ class Map3DState { content = composeView autoCloseEnabled = config.autoCloseEnabled autoPanEnabled = config.autoPanEnabled + config.anchorOffset?.let { anchorOffset = it } + config.popoverStyle?.let { popoverStyle = it } }, ) - popover.show() + // When PopoverManagerImpl first receives the anchor's DrawingState, popover.content is + // still View.GONE (width = 0, height = 0), so its initial (x, y) is placed at the raw + // anchor coordinates before ComposeView measures. Adjust (x, y) when layout size changes. + popover.content.addOnLayoutChangeListener { + view, + left, + top, + right, + bottom, + oldLeft, + oldTop, + oldRight, + oldBottom, + -> + val oldWidth = oldRight - oldLeft + val oldHeight = oldBottom - oldTop + val newWidth = right - left + val newHeight = bottom - top + if ((oldWidth != newWidth || oldHeight != newHeight) && + (view.x != 0f || view.y != 0f) + ) { + view.x -= (newWidth - oldWidth) / 2f + view.y -= (newHeight - oldHeight).toFloat() + } + } + + if (config.startVisible) { + popover.show() + } + config.onPopoverCreated?.invoke(popover) return popover } @@ -296,5 +368,9 @@ class Map3DState { models.clear() popovers.values.forEach { it.second.remove() } popovers.clear() + lastSyncedCamera = null + lastSyncedCameraRestriction = null + lastSyncedMapMode = null + hasCalledOnMapReady = false } } diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt index 8458dc85..44bdfa1b 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt @@ -1,9 +1,12 @@ package com.google.maps.android.compose3d +import com.google.android.gms.maps3d.model.Glyph import com.google.android.gms.maps3d.model.Hole +import com.google.android.gms.maps3d.model.ImageView import com.google.android.gms.maps3d.model.markerOptions import com.google.android.gms.maps3d.model.modelOptions import com.google.android.gms.maps3d.model.orientation +import com.google.android.gms.maps3d.model.pinConfiguration import com.google.android.gms.maps3d.model.polygonOptions import com.google.android.gms.maps3d.model.polylineOptions import com.google.android.gms.maps3d.model.vector3D @@ -32,10 +35,52 @@ fun MarkerConfig.toMarkerOptions(overrideId: String? = null) = markerOptions { position = this@toMarkerOptions.position.toValidLocation() altitudeMode = this@toMarkerOptions.altitudeMode label = this@toMarkerOptions.label + zIndex = this@toMarkerOptions.zIndex isExtruded = this@toMarkerOptions.isExtruded isDrawnWhenOccluded = this@toMarkerOptions.isDrawnWhenOccluded collisionBehavior = this@toMarkerOptions.collisionBehavior styleView?.let { setStyle(it) } + pinConfig?.let { pin -> + setStyle( + pinConfiguration { + pin.scale?.let { scale = it } + pin.backgroundColor?.let { backgroundColor = it } + pin.borderColor?.let { borderColor = it } + pin.glyph?.let { glyphConfig -> + val sdkGlyph = when (glyphConfig) { + is GlyphConfig.Color -> Glyph.fromColor(glyphConfig.color) + + is GlyphConfig.Text -> { + if (glyphConfig.color != null) { + Glyph.fromColor(glyphConfig.color).apply { + setText(glyphConfig.text) + } + } else { + Glyph.fromText(glyphConfig.text) + } + } + + is GlyphConfig.Circle -> { + Glyph.fromCircle().apply { + glyphConfig.color?.let { color = it } + } + } + + is GlyphConfig.Image -> { + val glyph = if (glyphConfig.color != null) { + Glyph.fromColor(glyphConfig.color) + } else { + Glyph.fromCircle() + } + glyph.setImage(ImageView(glyphConfig.imageResId)) + glyph + } + } + setGlyph(sdkGlyph) + } + }, + ) + } } /** @@ -49,6 +94,8 @@ fun PolygonConfig.toPolygonOptions(overrideId: String? = null) = polygonOptions strokeColor = this@toPolygonOptions.strokeColor strokeWidth = this@toPolygonOptions.strokeWidth.toDouble() altitudeMode = this@toPolygonOptions.altitudeMode + geodesic = this@toPolygonOptions.geodesic + drawsOccludedSegments = this@toPolygonOptions.drawsOccludedSegments } /** diff --git a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt index 44a83d05..e7468701 100644 --- a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt +++ b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt @@ -128,6 +128,8 @@ class MappersTest { strokeColor = Color.GREEN, strokeWidth = 3f, altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + geodesic = true, + drawsOccludedSegments = true, ) val options = config.toPolygonOptions() @@ -144,6 +146,8 @@ class MappersTest { assertEquals(Color.GREEN, options.strokeColor) assertEquals(3.0, options.strokeWidth, 0.0) assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode) + assertEquals(true, options.geodesic) + assertEquals(true, options.drawsOccludedSegments) } @Test