From 50b78dd4043544a9d8d5b5530901fc6398a8f20e Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:53:25 -0600 Subject: [PATCH 1/4] feat: add z-index support across geometry styles, layer mappers, and demo Follow-up to #1785 to complete z-index support across the remaining geometry styles (PointStyle, LineStyle), layer mappers (GeoJsonLayer, KmlLayer), and add an interactive demo and visual regression test suite. Key changes: - Model Layer: Add zIndex property (default 0.0f) to PointStyle and LineStyle with KDoc. - Renderer Engine: Propagate style.zIndex to MarkerOptions, AdvancedMarkerOptions, and PolylineOptions in MapViewRenderer. - Layer Bridges: Forward getZIndex() in GeoJsonLayer and inline zIndex in KmlLayer when converting to platform-agnostic models. - Interactive Sample: Add ZIndexDemoActivity showcasing in-place zIndex updates and reset for overlapping polygons, polylines, and markers with Material 3 filled tonal icon buttons and reset action. - Testing: - Unit tests verifying z-index propagation in MapViewRendererTest, GeoJsonLayerObserverTest, and KmlLayerOnMapTest. - On-device visual regression test (ZIndexVisualTest) verifying initial stacking hierarchy, dynamic updates, and reset action. --- .../maps/android/data/geojson/GeoJsonLayer.kt | 4 + .../google/maps/android/data/kml/KmlLayer.kt | 5 + .../data/renderer/mapview/MapViewRenderer.kt | 19 +- .../maps/android/data/renderer/model/Style.kt | 6 + .../data/geojson/GeoJsonLayerObserverTest.kt | 78 ++++ .../android/data/kml/KmlLayerOnMapTest.kt | 34 ++ .../data/renderer/MapViewRendererTest.kt | 77 +++- .../android/utils/demo/ZIndexVisualTest.kt | 177 ++++++++ demo/src/main/AndroidManifest.xml | 3 + .../maps/android/utils/demo/MainActivity.kt | 1 + .../android/utils/demo/ZIndexDemoActivity.kt | 409 ++++++++++++++++++ demo/src/main/res/drawable/bg_dot_coral.xml | 20 + demo/src/main/res/drawable/bg_dot_gold.xml | 20 + demo/src/main/res/drawable/bg_dot_green.xml | 20 + demo/src/main/res/drawable/bg_dot_purple.xml | 20 + demo/src/main/res/drawable/bg_dot_teal.xml | 20 + demo/src/main/res/drawable/bg_zindex_pill.xml | 21 + demo/src/main/res/drawable/ic_add_24.xml | 26 ++ demo/src/main/res/drawable/ic_remove_24.xml | 26 ++ .../main/res/drawable/ic_restart_alt_24.xml | 26 ++ .../main/res/layout/activity_zindex_demo.xml | 401 +++++++++++++++++ demo/src/main/res/values/strings.xml | 14 + 22 files changed, 1425 insertions(+), 2 deletions(-) create mode 100644 demo/src/androidTest/java/com/google/maps/android/utils/demo/ZIndexVisualTest.kt create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt create mode 100644 demo/src/main/res/drawable/bg_dot_coral.xml create mode 100644 demo/src/main/res/drawable/bg_dot_gold.xml create mode 100644 demo/src/main/res/drawable/bg_dot_green.xml create mode 100644 demo/src/main/res/drawable/bg_dot_purple.xml create mode 100644 demo/src/main/res/drawable/bg_dot_teal.xml create mode 100644 demo/src/main/res/drawable/bg_zindex_pill.xml create mode 100644 demo/src/main/res/drawable/ic_add_24.xml create mode 100644 demo/src/main/res/drawable/ic_remove_24.xml create mode 100644 demo/src/main/res/drawable/ic_restart_alt_24.xml create mode 100644 demo/src/main/res/layout/activity_zindex_demo.xml diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt index 7de77a530..af09642bf 100644 --- a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt @@ -295,6 +295,7 @@ public class GeoJsonLayer : Layer { anchorU = pointStyle.getAnchorU(), anchorV = pointStyle.getAnchorV(), heading = pointStyle.getRotation(), + zIndex = pointStyle.getZIndex(), ) } @@ -304,6 +305,7 @@ public class GeoJsonLayer : Layer { color = lineStyle.color, width = lineStyle.getWidth(), geodesic = lineStyle.isGeodesic(), + zIndex = lineStyle.getZIndex(), ) } @@ -314,6 +316,7 @@ public class GeoJsonLayer : Layer { strokeColor = polygonStyle.getStrokeColor(), strokeWidth = polygonStyle.getStrokeWidth(), geodesic = polygonStyle.isGeodesic(), + zIndex = polygonStyle.getZIndex(), ) } @@ -325,6 +328,7 @@ public class GeoJsonLayer : Layer { strokeColor = polygonStyle.getStrokeColor(), strokeWidth = polygonStyle.getStrokeWidth(), geodesic = polygonStyle.isGeodesic(), + zIndex = polygonStyle.getZIndex(), ) } else null } diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt index 9097cb690..5e0373414 100644 --- a/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt @@ -395,6 +395,7 @@ public class KmlLayer : Layer { inline?.mMarkerColor?.let { hue -> Color.HSVToColor(floatArrayOf(hue, 1f, 1f)) } ?: Color.BLACK, iconUrl = inline?.getIconUrl(), + zIndex = inline?.mMarkerOptions?.zIndex ?: 0f, ) } @@ -402,6 +403,8 @@ public class KmlLayer : Layer { com.google.maps.android.data.renderer.model.LineStyle( color = inline?.mPolylineOptions?.color ?: 0xFF000000.toInt(), width = inline?.mPolylineOptions?.width ?: 1.0f, + geodesic = inline?.mPolylineOptions?.isGeodesic ?: false, + zIndex = inline?.mPolylineOptions?.zIndex ?: 0f, ) } @@ -410,6 +413,8 @@ public class KmlLayer : Layer { fillColor = inline?.mPolygonOptions?.fillColor ?: 0x00000000, strokeColor = inline?.mPolygonOptions?.strokeColor ?: 0xFF000000.toInt(), strokeWidth = inline?.mPolygonOptions?.strokeWidth ?: 1.0f, + geodesic = inline?.mPolygonOptions?.isGeodesic ?: false, + zIndex = inline?.mPolygonOptions?.zIndex ?: 0f, ) } diff --git a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt index 9c85d249b..6dc89ccc4 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt @@ -222,7 +222,7 @@ class MapViewRenderer( if (bitmap != null) { try { mapOverlay.setImage(BitmapDescriptorFactory.fromBitmap(bitmap)) - mapOverlay.isVisible = style?.visibility ?: true // Restore visibility + mapOverlay.isVisible = style.visibility // Restore visibility } catch (e: Exception) { // Overlay might be removed } @@ -301,6 +301,7 @@ class MapViewRenderer( it.heading?.let { heading -> markerOptions.rotation(heading) } markerOptions.anchor(it.anchorU, it.anchorV) markerOptions.alpha(android.graphics.Color.alpha(it.color) / 255.0f) + markerOptions.zIndex(it.zIndex) } return markerOptions } @@ -333,6 +334,7 @@ class MapViewRenderer( .setBorderColor(android.graphics.Color.WHITE) // Default border .build() markerOptions.icon(BitmapDescriptorFactory.fromPinConfig(pinConfig)) + markerOptions.zIndex(it.zIndex) // AdvancedMarkerOptions does not support rotation directly in the same way as MarkerOptions for flat icons, // but it supports collision behavior etc. @@ -345,6 +347,13 @@ class MapViewRenderer( return markerOptions } + /** + * Creates the [PolylineOptions] for rendering a [LineString]. + * + * Applies styling properties from [LineStyle], including color, stroke width, + * geodesic curvature, and [LineStyle.zIndex] (which determines its draw order + * relative to other map features). + */ private fun createPolylineOptions( lineString: LineString, style: LineStyle?, @@ -355,10 +364,18 @@ class MapViewRenderer( polylineOptions.color(it.color) polylineOptions.width(it.width) polylineOptions.geodesic(it.geodesic) + polylineOptions.zIndex(it.zIndex) } return polylineOptions } + /** + * Creates the [PolygonOptions] for rendering a [Polygon]. + * + * Applies styling properties from [PolygonStyle], including fill color, stroke color, + * stroke width, geodesic curvature, and [PolygonStyle.zIndex] (which determines its draw order + * relative to other map features). + */ private fun createPolygonOptions( polygon: Polygon, style: PolygonStyle?, diff --git a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt index 858bdb054..c11ea769b 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt @@ -22,6 +22,7 @@ package com.google.maps.android.data.renderer.model */ sealed interface Style +// [START maps_android_utils_style_models] /** * A data class representing the style for a PointGeometry. * @@ -31,6 +32,7 @@ sealed interface Style * @property anchorU The U-coordinate of the icon's anchor point, as a ratio of the icon's width (0.0 to 1.0). Defaults to 0.5 (center). * @property anchorV The V-coordinate of the icon's anchor point, as a ratio of the icon's height (0.0 to 1.0). Defaults to 1.0 (bottom). * @property scale The scale factor for the icon. Defaults to 1.0. + * @property zIndex The z-index of the marker. Higher values are drawn above lower values. Defaults to 0.0. */ data class PointStyle( val color: Int = 0xFF000000.toInt(), // Default to black @@ -39,6 +41,7 @@ data class PointStyle( val anchorU: Float = 0.5f, val anchorV: Float = 1.0f, val scale: Float = 1.0f, + val zIndex: Float = 0.0f, ) : Style /** @@ -47,11 +50,13 @@ data class PointStyle( * @property color The color of the line, as an integer (e.g., ARGB) or hex string. Defaults to black. * @property width The width of the line in pixels. Defaults to 1.0. * @property geodesic Indicates whether the line should be drawn as a geodesic (true) or a straight line (false). Defaults to false. + * @property zIndex The z-index of the line. Higher values are drawn above lower values. Defaults to 0.0. */ data class LineStyle( val color: Int = 0xFF000000.toInt(), // Default to black val width: Float = 1.0f, val geodesic: Boolean = false, + val zIndex: Float = 0.0f, ) : Style /** @@ -70,3 +75,4 @@ data class PolygonStyle( val geodesic: Boolean = false, val zIndex: Float = 0.0f, ) : Style +// [END maps_android_utils_style_models] diff --git a/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerObserverTest.kt b/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerObserverTest.kt index 338b760ab..a18579947 100644 --- a/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerObserverTest.kt +++ b/data/src/test/java/com/google/maps/android/data/geojson/GeoJsonLayerObserverTest.kt @@ -16,25 +16,45 @@ package com.google.maps.android.data.geojson import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions import com.google.android.gms.maps.model.Polygon import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions import com.google.maps.android.data.Feature import io.mockk.Runs import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot +import io.mockk.unmockkStatic import io.mockk.verify import org.json.JSONObject +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertSame +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class GeoJsonLayerObserverTest { + @Before + fun setUp() { + mockkStatic(BitmapDescriptorFactory::class) + every { BitmapDescriptorFactory.defaultMarker(any()) } returns mockk() + } + + @After + fun tearDown() { + unmockkStatic(BitmapDescriptorFactory::class) + } + @Test fun addedPolygonStyleChange_removesAndRedrawsFeature() { val map = mockk(relaxed = true) @@ -196,6 +216,52 @@ class GeoJsonLayerObserverTest { assertSame(secondFeature, clickedFeature) } + @Test + fun addedPointStyle_appliesZIndexToMarker() { + val map = mockk(relaxed = true) + val marker = mockk(relaxed = true) + val options = mutableListOf() + every { map.addMarker(capture(options)) } returns marker + val layer = emptyLayer(map) + val (feature, _) = pointFeature(8.5f) + + layer.addLayerToMap() + layer.addFeature(feature) + + assertEquals(8.5f, options.single().zIndex, 0.001f) + } + + @Test + fun addedLineStringStyle_appliesZIndexToPolyline() { + val map = mockk(relaxed = true) + val polyline = mockk(relaxed = true) + val options = mutableListOf() + every { map.addPolyline(capture(options)) } returns polyline + val layer = emptyLayer(map) + val (feature, _) = lineStringFeature(4.5f) + + layer.addLayerToMap() + layer.addFeature(feature) + + assertEquals(4.5f, options.single().zIndex, 0.001f) + } + + @Test + fun addedPolygonStyle_appliesZIndexToPolygon() { + val map = mockk(relaxed = true) + val polygon = mockk(relaxed = true) + val options = mutableListOf() + every { map.addPolygon(capture(options)) } returns polygon + val layer = emptyLayer(map) + val (feature, style) = polygonFeature(INITIAL_COLOR) + style.setZIndex(3.5f) + + layer.addLayerToMap() + layer.addFeature(feature) + + assertEquals(3.5f, options.single().zIndex, 0.001f) + } + private fun emptyLayer(map: GoogleMap): GeoJsonLayer = GeoJsonLayer( map, @@ -218,6 +284,18 @@ class GeoJsonLayerObserverTest { return GeoJsonFeature(geometry, null, null, null).also { it.polygonStyle = style } to style } + private fun pointFeature(zIndex: Float): Pair { + val geometry = GeoJsonPoint(LatLng(10.0, 20.0)) + val style = GeoJsonPointStyle().apply { setZIndex(zIndex) } + return GeoJsonFeature(geometry, null, null, null).also { it.pointStyle = style } to style + } + + private fun lineStringFeature(zIndex: Float): Pair { + val geometry = GeoJsonLineString(listOf(LatLng(0.0, 0.0), LatLng(1.0, 1.0))) + val style = GeoJsonLineStringStyle().apply { setZIndex(zIndex) } + return GeoJsonFeature(geometry, null, null, null).also { it.lineStringStyle = style } to style + } + private fun multiPolygonFeature(fillColor: Int): Pair { val firstPolygon = polygon(0.0) val secondPolygon = polygon(2.0) diff --git a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt index 16effa6ec..e94f69e70 100644 --- a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt +++ b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt @@ -61,4 +61,38 @@ class KmlLayerOnMapTest { layer.removeLayerFromMap() assertFalse(layer.isLayerOnMap()) } + + @Test + fun addLayerToMap_rendersPointsLinesAndPolygons() { + io.mockk.mockkStatic(com.google.android.gms.maps.model.BitmapDescriptorFactory::class) + io.mockk.every { com.google.android.gms.maps.model.BitmapDescriptorFactory.defaultMarker(any()) } returns io.mockk.mockk() + try { + val kml = """ + + + + + 10.0,20.0 + + + 10.0,20.0 11.0,21.0 + + + + + 0.0,0.0 1.0,0.0 1.0,1.0 0.0,0.0 + + + + + + """.trimIndent() + val context = ApplicationProvider.getApplicationContext() + val layer = KmlLayer(mockk(relaxed = true), kml.byteInputStream(), context) + layer.addLayerToMap() + assertTrue(layer.isLayerOnMap()) + } finally { + io.mockk.unmockkStatic(com.google.android.gms.maps.model.BitmapDescriptorFactory::class) + } + } } diff --git a/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt b/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt index 590df03a9..b1bc3eb57 100644 --- a/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt +++ b/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt @@ -17,6 +17,7 @@ package com.google.maps.android.data.renderer import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.MarkerOptions @@ -32,15 +33,20 @@ import com.google.maps.android.data.renderer.model.LineStyle import com.google.maps.android.data.renderer.model.MultiGeometry import com.google.maps.android.data.renderer.model.Point import com.google.maps.android.data.renderer.model.PointGeometry +import com.google.maps.android.data.renderer.model.PointStyle import com.google.maps.android.data.renderer.model.Polygon import com.google.maps.android.data.renderer.model.PolygonStyle import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot +import io.mockk.unmockkStatic import io.mockk.verify +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertSame +import org.junit.Before import org.junit.Test /** @@ -48,6 +54,18 @@ import org.junit.Test * feature models to Google Maps SDK marker options. */ class MapViewRendererTest { + @Before + fun setUp() { + mockkStatic(BitmapDescriptorFactory::class) + every { BitmapDescriptorFactory.defaultMarker(any()) } returns mockk() + every { BitmapDescriptorFactory.fromPinConfig(any()) } returns mockk() + } + + @After + fun tearDown() { + unmockkStatic(BitmapDescriptorFactory::class) + } + @Test fun testAddFeaturePoint_setsTitleAndSnippetFromProperties() { // Given @@ -272,7 +290,7 @@ class MapViewRendererTest { val childLine = LineString(points = listOf(Point(0.0, 0.0), Point(1.0, 1.0))) val multiGeometry = MultiGeometry(geometries = listOf(childLine)) - val style = LineStyle(color = 0xFF0000FF.toInt(), width = 3.0f) + val style = LineStyle(color = 0xFF0000FF.toInt(), width = 3.0f, zIndex = 4.2f) val feature = Feature(geometry = multiGeometry, style = style) // When @@ -283,6 +301,63 @@ class MapViewRendererTest { val capturedOptions = optionsSlot.captured assertEquals(0xFF0000FF.toInt(), capturedOptions.color) assertEquals(3.0f, capturedOptions.width, 0.001f) + assertEquals(4.2f, capturedOptions.zIndex, 0.001f) + } + + @Test + fun testAddFeaturePoint_legacyMarker_appliesPointStyleZIndex() { + // Given + val mockMap = mockk(relaxed = true) + val mockMarker = mockk(relaxed = true) + val mockIconProvider = mockk(relaxed = true) + + val optionsSlot = slot() + every { mockMap.addMarker(capture(optionsSlot)) } returns mockMarker + + val renderer = MapViewRenderer(mockMap, mockIconProvider) + renderer.useAdvancedMarkers = false + + val feature = + Feature( + geometry = PointGeometry(Point(41.942, -111.620)), + style = PointStyle(zIndex = 3.5f), + ) + + // When + renderer.addFeature(feature) + + // Then + verify(exactly = 1) { mockMap.addMarker(any()) } + val capturedOptions = optionsSlot.captured + assertEquals(3.5f, capturedOptions.zIndex, 0.001f) + } + + @Test + fun testAddFeaturePoint_advancedMarker_appliesPointStyleZIndex() { + // Given + val mockMap = mockk(relaxed = true) + val mockMarker = mockk(relaxed = true) + val mockIconProvider = mockk(relaxed = true) + + val optionsSlot = slot() + every { mockMap.addMarker(capture(optionsSlot)) } returns mockMarker + + val renderer = MapViewRenderer(mockMap, mockIconProvider) + renderer.useAdvancedMarkers = true + + val feature = + Feature( + geometry = PointGeometry(Point(41.942, -111.620)), + style = PointStyle(zIndex = 6.0f), + ) + + // When + renderer.addFeature(feature) + + // Then + verify(exactly = 1) { mockMap.addMarker(any()) } + val capturedOptions = optionsSlot.captured + assertEquals(6.0f, capturedOptions.zIndex, 0.001f) } @Test diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/ZIndexVisualTest.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/ZIndexVisualTest.kt new file mode 100644 index 000000000..56b6a8ee0 --- /dev/null +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/ZIndexVisualTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.utils.demo + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +/** + * Visual regression and LLM-assisted verification test for z-index layering. + * + * Verifies that: + * 1. Initial z-index values on [PolygonStyle], [LineStyle], and [PointStyle] dictate the + * visual stacking order on the Google Map via [MapViewRenderer]. + * 2. Dynamically lowering a shape's z-index (e.g., lowering Polygon 2 below Polygon 1) + * causes the shape to immediately render behind the higher z-index shape on the map. + */ +// [START maps_android_utils_zindex_visual_test] +@RunWith(AndroidJUnit4::class) +class ZIndexVisualTest : BaseVisualTest() { + + private fun launchZIndexDemo() { + val intent = Intent(context, ZIndexDemoActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + context.startActivity(intent) + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 15000) + waitForMapRendering(5) + } + + /** + * Verifies the initial stacking hierarchy: + * - Polygon 1 (Coral) at z: 1.0 + * - Polygon 2 (Teal) at z: 2.0 (Teal is on top of Coral in the overlap) + * - Polyline 1 (Gold) at z: 3.0 + * - Polyline 2 (Purple) at z: 4.0 + * - Center Marker (Green) at z: 5.0 (on top of all shapes) + */ + @Test + fun verifyInitialZIndexLayering() = runBlocking { + launchZIndexDemo() + + val screenshotBitmap = captureScreenshot("zindex_initial_layering.png") + + val prompt = """ + Task: Analyze the provided Android map screenshot to verify visual z-index layering. + Criteria Checklist: + 1. The map displays overlapping colored shapes (polygons and polylines) centered in an urban area. + 2. Polygon 1 (Coral / Orange) is on the left and Polygon 2 (Teal / Cyan) is on the right, overlapping in the center. + 3. In the central rectangular overlap region between the two polygons, Polygon 2 (Teal) is rendered on TOP of Polygon 1 (Coral). + 4. The crossing polylines (Gold and Purple) and the center green marker are rendered on top of the polygons. + 5. The bottom control card displays "Z-Index Layering" with controls for each shape. + + Decision Logic: + If all criteria are met, answer YES. + If any criterion is not met, answer NO. + Then provide a brief explanation. + """.trimIndent() + + verifyScreenshotWithGoldenFallback( + testName = "zindex_initial_layering", + screenshotBitmap = screenshotBitmap, + prompt = prompt, + passCondition = { it.contains("YES", ignoreCase = true) }, + ) + } + + /** + * Verifies that dynamically lowering Polygon 2's z-index from 2.0 down to 0.0 + * causes Polygon 2 to move behind Polygon 1 (Coral at 1.0). + */ + @Test + fun verifyDynamicZIndexLayeringUpdate() = runBlocking { + launchZIndexDemo() + + // Locate the minus button for Polygon 2 and click it twice (2.0 -> 1.0 -> 0.0) + val polygon2Minus = uiDevice.findObject(By.res(context.packageName, "polygon2Minus")) + assertNotNull("polygon2Minus button must be present in layout", polygon2Minus) + + polygon2Minus.click() + TimeUnit.MILLISECONDS.sleep(500) + polygon2Minus.click() + TimeUnit.MILLISECONDS.sleep(1500) + + // Wait for the map to re-render + waitForMapRendering(3) + + val updatedScreenshot = captureScreenshot("zindex_lowered_teal_layering.png") + + val prompt = """ + Task: Analyze the provided Android map screenshot to verify visual z-index layering after dynamic update. + Criteria Checklist: + 1. Polygon 2 (Teal) z-index was lowered from 2.0 to 0.0, which is below Polygon 1 (Coral) at 1.0. + 2. In the central overlap region between the two polygons, Polygon 1 (Coral / Orange) is now visibly rendered ON TOP of Polygon 2 (Teal / Cyan). + 3. The crossing polylines and green marker remain above the polygons. + 4. The controls card reflects Polygon 2 at "z: 0.0" and Polygon 1 at "z: 1.0". + + Decision Logic: + If all criteria are met, answer YES. + If any criterion is not met, answer NO. + Then provide a brief explanation. + """.trimIndent() + + verifyScreenshotWithGoldenFallback( + testName = "zindex_lowered_teal_layering", + screenshotBitmap = updatedScreenshot, + prompt = prompt, + passCondition = { it.contains("YES", ignoreCase = true) }, + ) + } + + /** + * Verifies that clicking the reset button restores all shapes to their initial z-index values + * and stacking order after modifications. + */ + @Test + fun verifyResetZIndices() = runBlocking { + launchZIndexDemo() + + // Modify Polygon 1's z-index (1.0 -> 2.0 -> 3.0) + val polygon1Plus = uiDevice.findObject(By.res(context.packageName, "polygon1Plus")) + assertNotNull("polygon1Plus button must be present in layout", polygon1Plus) + polygon1Plus.click() + TimeUnit.MILLISECONDS.sleep(300) + polygon1Plus.click() + TimeUnit.MILLISECONDS.sleep(500) + + // Click the reset button + val resetButton = uiDevice.findObject(By.res(context.packageName, "resetButton")) + assertNotNull("resetButton must be present in layout", resetButton) + resetButton.click() + TimeUnit.MILLISECONDS.sleep(1000) + + val resetScreenshot = captureScreenshot("zindex_reset_layering.png") + + val prompt = """ + Task: Analyze the provided Android map screenshot to verify visual z-index layering after resetting. + Criteria Checklist: + 1. The map displays overlapping colored shapes (polygons and polylines) centered in an urban area. + 2. Polygon 1 (Coral) has been reset to "z: 1.0" and Polygon 2 (Teal) is at "z: 2.0". + 3. In the central overlap region, Polygon 2 (Teal) is visibly rendered ON TOP of Polygon 1 (Coral). + 4. The bottom control card displays the reset icon button and all initial z-index values (Coral: 1.0, Teal: 2.0, Gold: 3.0, Purple: 4.0, Green: 5.0). + + Decision Logic: + If all criteria are met, answer YES. + If any criterion is not met, answer NO. + Then provide a brief explanation. + """.trimIndent() + + verifyScreenshotWithGoldenFallback( + testName = "zindex_reset_layering", + screenshotBitmap = resetScreenshot, + prompt = prompt, + passCondition = { it.contains("YES", ignoreCase = true) }, + ) + } +} +// [END maps_android_utils_zindex_visual_test] diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index d45191b31..487ef5b60 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -137,6 +137,9 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt index 3276fced0..587048a55 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt @@ -114,6 +114,7 @@ class MainActivity : ComponentActivity() { Demo(R.string.demo_title_multi_layer, MultiLayerDemoActivity::class.java), Demo(R.string.demo_title_transit_layer, TransitLayerDemoActivity::class.java), Demo(R.string.demo_title_renderer, RendererDemoActivity::class.java), + Demo(R.string.demo_title_zindex, ZIndexDemoActivity::class.java), ), ), DemoGroup( diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt new file mode 100644 index 000000000..ddf9b6ae1 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt @@ -0,0 +1,409 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.utils.demo + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.utils.demo.databinding.ActivityZindexDemoBinding + +/** + * Common interface representing a styled map object whose visual layering ([zIndex]) + * can be drawn or refreshed in-place on a [GoogleMap]. + */ +// [START maps_android_utils_zindex_shape_interface] +sealed interface MapShape { + val id: String + val name: String + val zIndex: Float + + /** + * Renders this shape onto the [map]. If the underlying native Google Maps SDK object + * has already been created, updates its properties (like [zIndex]) in-place without + * destroying and recreating the geometry. + * + * @param map The active [GoogleMap] instance. + * @return A copy of this [MapShape] holding the native map handle. + */ + fun drawOrRefresh(map: GoogleMap): MapShape + + /** + * Removes the native Google Maps SDK object from the map and releases its reference. + */ + fun remove() + + /** + * Creates a copy of this shape with an updated [newZIndex]. + */ + fun withZIndex(newZIndex: Float): MapShape +} + +/** + * Represents a styled Polygon on the map with dynamic [zIndex] control. + * + * @property id Unique identifier for the shape in the registry. + * @property name Human-readable label for UI displays. + * @property zIndex Current stacking order value (higher draws above lower). + * @property points Ordered vertices defining the polygon's outer boundary. + * @property fillColor ARGB integer fill color of the polygon. + * @property strokeColor ARGB integer stroke border color of the polygon. + * @property strokeWidth Stroke width in screen pixels. + * @property nativePolygon The active Google Maps SDK [Polygon] handle, if drawn. + */ +data class PolygonShape( + override val id: String, + override val name: String, + override val zIndex: Float, + val points: List, + val fillColor: Int, + val strokeColor: Int, + val strokeWidth: Float, + private val nativePolygon: Polygon? = null, +) : MapShape { + + override fun drawOrRefresh(map: GoogleMap): PolygonShape { + return if (nativePolygon != null) { + // Update the existing native object's zIndex directly without recreating the GL shape + nativePolygon.zIndex = zIndex + this + } else { + val polygon = map.addPolygon( + PolygonOptions() + .addAll(points) + .fillColor(fillColor) + .strokeColor(strokeColor) + .strokeWidth(strokeWidth) + .zIndex(zIndex) + ) + copy(nativePolygon = polygon) + } + } + + override fun remove() { + nativePolygon?.remove() + } + + override fun withZIndex(newZIndex: Float): PolygonShape = copy(zIndex = newZIndex) +} + +/** + * Represents a styled Polyline on the map with dynamic [zIndex] control. + * + * @property id Unique identifier for the shape in the registry. + * @property name Human-readable label for UI displays. + * @property zIndex Current stacking order value (higher draws above lower). + * @property points Ordered vertices defining the polyline path. + * @property color ARGB integer color of the line. + * @property width Stroke width in screen pixels. + * @property nativePolyline The active Google Maps SDK [Polyline] handle, if drawn. + */ +data class PolylineShape( + override val id: String, + override val name: String, + override val zIndex: Float, + val points: List, + val color: Int, + val width: Float, + private val nativePolyline: Polyline? = null, +) : MapShape { + + override fun drawOrRefresh(map: GoogleMap): PolylineShape { + return if (nativePolyline != null) { + // Update the existing native object's zIndex directly in place + nativePolyline.zIndex = zIndex + this + } else { + val polyline = map.addPolyline( + PolylineOptions() + .addAll(points) + .color(color) + .width(width) + .zIndex(zIndex) + ) + copy(nativePolyline = polyline) + } + } + + override fun remove() { + nativePolyline?.remove() + } + + override fun withZIndex(newZIndex: Float): PolylineShape = copy(zIndex = newZIndex) +} + +/** + * Represents a styled Marker on the map with dynamic [zIndex] control. + * + * @property id Unique identifier for the shape in the registry. + * @property name Human-readable label for UI displays. + * @property zIndex Current stacking order value (higher draws above lower). + * @property position Geographic coordinate of the marker. + * @property title Info window title text. + * @property hue Marker color hue (0.0 to 360.0). + * @property nativeMarker The active Google Maps SDK [Marker] handle, if drawn. + */ +data class MarkerShape( + override val id: String, + override val name: String, + override val zIndex: Float, + val position: LatLng, + val title: String, + val hue: Float, + private val nativeMarker: Marker? = null, +) : MapShape { + + override fun drawOrRefresh(map: GoogleMap): MarkerShape { + return if (nativeMarker != null) { + // Update the existing native marker's zIndex directly in place + nativeMarker.zIndex = zIndex + this + } else { + val marker = map.addMarker( + MarkerOptions() + .position(position) + .title(title) + .icon(BitmapDescriptorFactory.defaultMarker(hue)) + .zIndex(zIndex) + ) + copy(nativeMarker = marker) + } + } + + override fun remove() { + nativeMarker?.remove() + } + + override fun withZIndex(newZIndex: Float): MarkerShape = copy(zIndex = newZIndex) +} +// [END maps_android_utils_zindex_shape_interface] + +/** + * Demonstrates how to control the visual layering (z-index) of overlapping map objects + * (polygons, polylines, and markers) using a unified [MapShape] architecture. + * + * Each shape can draw and refresh its [zIndex] in-place without destroying and recreating + * the underlying Google Maps SDK objects. Shapes are tracked in a [LinkedHashMap] keyed + * by their unique object ID. + */ +// [START maps_android_utils_zindex_sample] +class ZIndexDemoActivity : BaseDemoActivity() { + + private lateinit var binding: ActivityZindexDemoBinding + + /** + * Registry of all active demo shapes, keyed by their unique shape ID. + * When a shape's z-index is modified, the updated shape replaces the previous entry here. + */ + private val shapes = LinkedHashMap() + + /** + * Binds a shape's ID to its corresponding UI row widgets. + */ + private data class ShapeRowBinding( + val shapeId: String, + val minusButton: View, + val plusButton: View, + val zIndexText: TextView, + ) + + private val rowBindings by lazy { + listOf( + ShapeRowBinding(ID_POLYGON_1, binding.polygon1Minus, binding.polygon1Plus, binding.polygon1ZIndexText), + ShapeRowBinding(ID_POLYGON_2, binding.polygon2Minus, binding.polygon2Plus, binding.polygon2ZIndexText), + ShapeRowBinding(ID_POLYLINE_1, binding.polyline1Minus, binding.polyline1Plus, binding.polyline1ZIndexText), + ShapeRowBinding(ID_POLYLINE_2, binding.polyline2Minus, binding.polyline2Plus, binding.polyline2ZIndexText), + ShapeRowBinding(ID_MARKER, binding.markerMinus, binding.markerPlus, binding.markerZIndexText), + ) + } + + companion object { + const val ID_POLYGON_1 = "polygon_1" + const val ID_POLYGON_2 = "polygon_2" + const val ID_POLYLINE_1 = "polyline_1" + const val ID_POLYLINE_2 = "polyline_2" + const val ID_MARKER = "marker" + + private val INITIAL_Z_INDICES = mapOf( + ID_POLYGON_1 to 1.0f, + ID_POLYGON_2 to 2.0f, + ID_POLYLINE_1 to 3.0f, + ID_POLYLINE_2 to 4.0f, + ID_MARKER to 5.0f, + ) + } + + override fun getLayoutId(): Int = R.layout.activity_zindex_demo + + override fun startDemo(isRestore: Boolean) { + val rootView = (findViewById(android.R.id.content)).getChildAt(0) + binding = ActivityZindexDemoBinding.bind(rootView) + + val map = map ?: return + + // Center on the demo shapes in San Francisco + val center = LatLng(37.7749, -122.4194) + map.moveCamera(CameraUpdateFactory.newLatLngZoom(center, 15f)) + + setupFeatures(map, center) + setupControls() + } + + /** + * Initializes the [MapShape] instances, draws them on the [map], and populates [shapes]. + */ + private fun setupFeatures(map: GoogleMap, center: LatLng) { + val lat = center.latitude + val lng = center.longitude + + val initialShapes = listOf( + // Polygon 1 (Coral) - covers the western half + PolygonShape( + id = ID_POLYGON_1, + name = getString(R.string.zindex_polygon_1), + zIndex = 1.0f, + points = listOf( + LatLng(lat - 0.005, lng - 0.008), + LatLng(lat + 0.005, lng - 0.008), + LatLng(lat + 0.005, lng + 0.002), + LatLng(lat - 0.005, lng + 0.002), + LatLng(lat - 0.005, lng - 0.008), + ), + fillColor = 0xCCFF5722.toInt(), + strokeColor = 0xFFD84315.toInt(), + strokeWidth = 6f, + ), + // Polygon 2 (Teal) - overlaps Polygon 1 in the center and extends east + PolygonShape( + id = ID_POLYGON_2, + name = getString(R.string.zindex_polygon_2), + zIndex = 2.0f, + points = listOf( + LatLng(lat - 0.004, lng - 0.002), + LatLng(lat + 0.004, lng - 0.002), + LatLng(lat + 0.004, lng + 0.008), + LatLng(lat - 0.004, lng + 0.008), + LatLng(lat - 0.004, lng - 0.002), + ), + fillColor = 0xCC00BCD4.toInt(), + strokeColor = 0xFF0097A7.toInt(), + strokeWidth = 6f, + ), + // Polyline 1 (Gold) - diagonal line crossing both polygons + PolylineShape( + id = ID_POLYLINE_1, + name = getString(R.string.zindex_polyline_1), + zIndex = 3.0f, + points = listOf( + LatLng(lat - 0.006, lng - 0.009), + LatLng(lat, lng), + LatLng(lat + 0.006, lng + 0.009), + ), + color = 0xFFFFD600.toInt(), + width = 16f, + ), + // Polyline 2 (Purple) - horizontal line cutting across the overlap + PolylineShape( + id = ID_POLYLINE_2, + name = getString(R.string.zindex_polyline_2), + zIndex = 4.0f, + points = listOf( + LatLng(lat + 0.002, lng - 0.009), + LatLng(lat - 0.002, lng + 0.009), + ), + color = 0xFF9C27B0.toInt(), + width = 16f, + ), + // Center Marker (Green) - positioned at the exact intersection of both lines and polygons + MarkerShape( + id = ID_MARKER, + name = getString(R.string.zindex_marker), + zIndex = 5.0f, + position = LatLng(lat, lng), + title = "Intersection Point", + hue = BitmapDescriptorFactory.HUE_GREEN, + ), + ) + + // Draw each shape on the map and store the rendered object in the shapes registry + for (shape in initialShapes) { + val rendered = shape.drawOrRefresh(map) + shapes[rendered.id] = rendered + } + } + + /** + * Binds stepper buttons and initial z-index labels for each shape. + */ + private fun setupControls() { + for (row in rowBindings) { + val shape = shapes[row.shapeId] ?: continue + row.zIndexText.text = getString(R.string.zindex_value_fmt, shape.zIndex) + row.minusButton.setOnClickListener { updateShapeZIndex(row.shapeId, -1.0f) } + row.plusButton.setOnClickListener { updateShapeZIndex(row.shapeId, +1.0f) } + } + binding.resetButton.setOnClickListener { resetZIndices() } + } + + /** + * Resets all shapes to their initial default z-index values and updates the map in-place. + */ + private fun resetZIndices() { + val map = map ?: return + for ((shapeId, defaultZIndex) in INITIAL_Z_INDICES) { + val currentShape = shapes[shapeId] ?: continue + val updatedShape = currentShape.withZIndex(defaultZIndex) + val renderedShape = updatedShape.drawOrRefresh(map) + shapes[shapeId] = renderedShape + rowBindings.firstOrNull { it.shapeId == shapeId }?.zIndexText?.text = + getString(R.string.zindex_value_fmt, renderedShape.zIndex) + } + } + + // [START maps_android_utils_zindex_update_sample] + /** + * Updates the z-index of the shape identified by [shapeId] by [delta]. + * Refreshes the shape on the map in-place and replaces the entry in [shapes]. + */ + private fun updateShapeZIndex(shapeId: String, delta: Float) { + val currentShape = shapes[shapeId] ?: return + val map = map ?: return + + // 1. Create an updated shape with the new zIndex + val updatedShape = currentShape.withZIndex(currentShape.zIndex + delta) + + // 2. Refresh the shape on the map (updates native map object in-place) + val renderedShape = updatedShape.drawOrRefresh(map) + + // 3. Replace the object in the map data structure with the same object id + shapes[shapeId] = renderedShape + + // 4. Update the corresponding UI label + rowBindings.firstOrNull { it.shapeId == shapeId }?.zIndexText?.text = + getString(R.string.zindex_value_fmt, renderedShape.zIndex) + } + // [END maps_android_utils_zindex_update_sample] +} +// [END maps_android_utils_zindex_sample] diff --git a/demo/src/main/res/drawable/bg_dot_coral.xml b/demo/src/main/res/drawable/bg_dot_coral.xml new file mode 100644 index 000000000..3b6ea1ea3 --- /dev/null +++ b/demo/src/main/res/drawable/bg_dot_coral.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/demo/src/main/res/drawable/bg_dot_gold.xml b/demo/src/main/res/drawable/bg_dot_gold.xml new file mode 100644 index 000000000..e9f645f2d --- /dev/null +++ b/demo/src/main/res/drawable/bg_dot_gold.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/demo/src/main/res/drawable/bg_dot_green.xml b/demo/src/main/res/drawable/bg_dot_green.xml new file mode 100644 index 000000000..cbee1d79f --- /dev/null +++ b/demo/src/main/res/drawable/bg_dot_green.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/demo/src/main/res/drawable/bg_dot_purple.xml b/demo/src/main/res/drawable/bg_dot_purple.xml new file mode 100644 index 000000000..347013ea3 --- /dev/null +++ b/demo/src/main/res/drawable/bg_dot_purple.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/demo/src/main/res/drawable/bg_dot_teal.xml b/demo/src/main/res/drawable/bg_dot_teal.xml new file mode 100644 index 000000000..32a120471 --- /dev/null +++ b/demo/src/main/res/drawable/bg_dot_teal.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/demo/src/main/res/drawable/bg_zindex_pill.xml b/demo/src/main/res/drawable/bg_zindex_pill.xml new file mode 100644 index 000000000..edb70901a --- /dev/null +++ b/demo/src/main/res/drawable/bg_zindex_pill.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/demo/src/main/res/drawable/ic_add_24.xml b/demo/src/main/res/drawable/ic_add_24.xml new file mode 100644 index 000000000..c4202a141 --- /dev/null +++ b/demo/src/main/res/drawable/ic_add_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/demo/src/main/res/drawable/ic_remove_24.xml b/demo/src/main/res/drawable/ic_remove_24.xml new file mode 100644 index 000000000..88c58a823 --- /dev/null +++ b/demo/src/main/res/drawable/ic_remove_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/demo/src/main/res/drawable/ic_restart_alt_24.xml b/demo/src/main/res/drawable/ic_restart_alt_24.xml new file mode 100644 index 000000000..a85533bfb --- /dev/null +++ b/demo/src/main/res/drawable/ic_restart_alt_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/demo/src/main/res/layout/activity_zindex_demo.xml b/demo/src/main/res/layout/activity_zindex_demo.xml new file mode 100644 index 000000000..20d036604 --- /dev/null +++ b/demo/src/main/res/layout/activity_zindex_demo.xml @@ -0,0 +1,401 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index 4c6b8d680..aa0ccae99 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -113,4 +113,18 @@ I am a cluster of size %d The latest version of the renderer is used. The legacy version of the renderer is used. + + + Z-Index Layering + Z-Index Layering + Adjust z-index to change shape stacking order + Polygon 1 (Coral) + Polygon 2 (Teal) + Polyline 1 (Gold) + Polyline 2 (Purple) + Center Marker (Green) + z: %.1f + Decrease z-index + Increase z-index + Reset z-indices From bfef69789f57c810385cffff2bf8977f14360b67 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:16:13 -0600 Subject: [PATCH 2/4] docs(demo): document marker z-index grouping and add in-app hint --- .../android/utils/demo/ZIndexDemoActivity.kt | 33 +++++++++++++++++-- .../main/res/layout/activity_zindex_demo.xml | 9 +++++ demo/src/main/res/values/strings.xml | 1 + 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt index ddf9b6ae1..cafbc4062 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt @@ -30,11 +30,19 @@ import com.google.android.gms.maps.model.Polyline import com.google.android.gms.maps.model.PolylineOptions import com.google.maps.android.utils.demo.databinding.ActivityZindexDemoBinding +// [START maps_android_utils_zindex_shape_interface] /** * Common interface representing a styled map object whose visual layering ([zIndex]) * can be drawn or refreshed in-place on a [GoogleMap]. + * + * **Note on zIndex Layering**: + * - When two shapes of the same type share identical [zIndex] values, the Google Maps SDK resolves + * their relative stacking order based on insertion or creation order. + * - Markers always render above other map overlays (polylines, polygons, circles, and ground overlays), + * regardless of the explicit [zIndex] value assigned to those shapes. This is because markers reside + * in a separate z-index group. For more details, see the official + * [Google Maps Markers Documentation](https://developers.google.com/maps/documentation/android-sdk/marker#z-index). */ -// [START maps_android_utils_zindex_shape_interface] sealed interface MapShape { val id: String val name: String @@ -157,9 +165,15 @@ data class PolylineShape( /** * Represents a styled Marker on the map with dynamic [zIndex] control. * + * **Note on zIndex Layering**: Markers always render above other map overlays (polylines, polygons, + * circles, and ground overlays), regardless of the explicit [zIndex] value assigned to those shapes. + * This is because markers reside in a separate z-index group; setting [zIndex] on a marker only + * controls its stacking order relative to other markers. For more details, see the official + * [Google Maps Markers Documentation](https://developers.google.com/maps/documentation/android-sdk/marker#z-index). + * * @property id Unique identifier for the shape in the registry. * @property name Human-readable label for UI displays. - * @property zIndex Current stacking order value (higher draws above lower). + * @property zIndex Current stacking order value (higher draws above lower relative to other markers). * @property position Geographic coordinate of the marker. * @property title Info window title text. * @property hue Marker color hue (0.0 to 360.0). @@ -200,6 +214,7 @@ data class MarkerShape( } // [END maps_android_utils_zindex_shape_interface] +// [START maps_android_utils_zindex_sample] /** * Demonstrates how to control the visual layering (z-index) of overlapping map objects * (polygons, polylines, and markers) using a unified [MapShape] architecture. @@ -207,8 +222,20 @@ data class MarkerShape( * Each shape can draw and refresh its [zIndex] in-place without destroying and recreating * the underlying Google Maps SDK objects. Shapes are tracked in a [LinkedHashMap] keyed * by their unique object ID. + * + * **Note on zIndex Layering**: Markers always render above other map overlays (polylines, + * polygons, circles, and ground overlays), regardless of the explicit [zIndex] value assigned + * to those shapes. This is because markers reside in a separate z-index group. For more details, + * see the official [Google Maps Markers Documentation](https://developers.google.com/maps/documentation/android-sdk/marker#z-index). + * + * Key API calls: + * - [com.google.android.gms.maps.model.Polygon.setZIndex] + * - [com.google.android.gms.maps.model.Polyline.setZIndex] + * - [com.google.android.gms.maps.model.Marker.setZIndex] + * - [com.google.android.gms.maps.GoogleMap.addPolygon] + * - [com.google.android.gms.maps.GoogleMap.addPolyline] + * - [com.google.android.gms.maps.GoogleMap.addMarker] */ -// [START maps_android_utils_zindex_sample] class ZIndexDemoActivity : BaseDemoActivity() { private lateinit var binding: ActivityZindexDemoBinding diff --git a/demo/src/main/res/layout/activity_zindex_demo.xml b/demo/src/main/res/layout/activity_zindex_demo.xml index 20d036604..244a1e60c 100644 --- a/demo/src/main/res/layout/activity_zindex_demo.xml +++ b/demo/src/main/res/layout/activity_zindex_demo.xml @@ -395,6 +395,15 @@ app:iconSize="18dp" /> + + diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index aa0ccae99..244ee7450 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -127,4 +127,5 @@ Decrease z-index Increase z-index Reset z-indices + Note: Markers reside in a separate z-index group and always render above other map overlays (polylines, polygons, circles, ground overlays), regardless of explicit z-index. From 87293870c6c3e1d79cfb6bc6ec73d4ff30d4cf33 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:56:20 -0600 Subject: [PATCH 3/4] feat(demo): add informational guide pop-up dialog to Z-Index demo --- .../android/utils/demo/ZIndexDemoActivity.kt | 24 +++++++++++++++++ demo/src/main/res/drawable/ic_info_24.xml | 26 +++++++++++++++++++ .../main/res/layout/activity_zindex_demo.xml | 20 +++++++------- demo/src/main/res/values/strings.xml | 17 +++++++++++- 4 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 demo/src/main/res/drawable/ic_info_24.xml diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt index cafbc4062..7ead8b924 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt @@ -15,9 +15,11 @@ */ package com.google.maps.android.utils.demo +import android.text.method.LinkMovementMethod import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.core.text.HtmlCompat import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.BitmapDescriptorFactory @@ -28,6 +30,7 @@ import com.google.android.gms.maps.model.Polygon import com.google.android.gms.maps.model.PolygonOptions import com.google.android.gms.maps.model.Polyline import com.google.android.gms.maps.model.PolylineOptions +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.maps.android.utils.demo.databinding.ActivityZindexDemoBinding // [START maps_android_utils_zindex_shape_interface] @@ -392,6 +395,27 @@ class ZIndexDemoActivity : BaseDemoActivity() { row.plusButton.setOnClickListener { updateShapeZIndex(row.shapeId, +1.0f) } } binding.resetButton.setOnClickListener { resetZIndices() } + binding.infoButton.setOnClickListener { showInfoDialog() } + } + + /** + * Displays an informative pop-up dialog explaining the sample's purpose, expected behavior, + * marker z-index grouping, and tie-breaking rules with links to official documentation. + */ + private fun showInfoDialog() { + val messageHtml = HtmlCompat.fromHtml( + getString(R.string.zindex_dialog_message), + HtmlCompat.FROM_HTML_MODE_LEGACY + ) + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.zindex_dialog_title) + .setMessage(messageHtml) + .setPositiveButton(R.string.zindex_dialog_close, null) + .show() + + // Enable clickable HTML links inside the dialog message + dialog.findViewById(android.R.id.message)?.movementMethod = + LinkMovementMethod.getInstance() } /** diff --git a/demo/src/main/res/drawable/ic_info_24.xml b/demo/src/main/res/drawable/ic_info_24.xml new file mode 100644 index 000000000..948658127 --- /dev/null +++ b/demo/src/main/res/drawable/ic_info_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/demo/src/main/res/layout/activity_zindex_demo.xml b/demo/src/main/res/layout/activity_zindex_demo.xml index 244a1e60c..231dc78a8 100644 --- a/demo/src/main/res/layout/activity_zindex_demo.xml +++ b/demo/src/main/res/layout/activity_zindex_demo.xml @@ -69,10 +69,21 @@ android:textAppearance="?attr/textAppearanceBodySmall" /> + + - - diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index 244ee7450..78d1f0cb5 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -127,5 +127,20 @@ Decrease z-index Increase z-index Reset z-indices - Note: Markers reside in a separate z-index group and always render above other map overlays (polylines, polygons, circles, ground overlays), regardless of explicit z-index. + Z-Index Layering Information + Z-Index Layering Guide + Close + Purpose:
+Demonstrates how to dynamically control the visual layering (stacking order) of map objects in-place on a Google Map without recreating geometries.

+What to Expect:
+• Increasing or decreasing a shape\'s z-index adjusts its draw priority relative to other overlays.
+• Raising Polygon 1 (Coral) above Polygon 2 (Teal) causes it to emerge on top in the overlapping region. Lowering it causes it to sink behind.

+Separate Marker Group:
+• Markers reside in a separate z-index group and always render above other map overlays (polylines, polygons, circles, and ground overlays), regardless of explicit z-index values.
+• Marker z-index only controls stacking relative to other markers.
+• See the Google Maps Markers Documentation for details.

+Tie Resolution:
+• When two shapes of the same type share identical z-index values, the Google Maps SDK resolves their stacking order based on insertion (creation) order. +]]>
From c2b070808772868d1f41648c75e9ecef1c1f5e5d Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:27:40 -0600 Subject: [PATCH 4/4] refactor: remove FQCNs, strip unneeded library region tags, and clean up private comments --- .../maps/android/data/renderer/model/Style.kt | 2 - .../android/data/kml/KmlLayerOnMapTest.kt | 10 +++-- .../android/utils/demo/ZIndexDemoActivity.kt | 43 ++++++------------- 3 files changed, 20 insertions(+), 35 deletions(-) diff --git a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt index c11ea769b..d01d6f517 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/model/Style.kt @@ -22,7 +22,6 @@ package com.google.maps.android.data.renderer.model */ sealed interface Style -// [START maps_android_utils_style_models] /** * A data class representing the style for a PointGeometry. * @@ -75,4 +74,3 @@ data class PolygonStyle( val geodesic: Boolean = false, val zIndex: Float = 0.0f, ) : Style -// [END maps_android_utils_style_models] diff --git a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt index e94f69e70..1b462c2ac 100644 --- a/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt +++ b/data/src/test/java/com/google/maps/android/data/kml/KmlLayerOnMapTest.kt @@ -18,8 +18,12 @@ package com.google.maps.android.data.kml import android.content.Context import androidx.test.core.app.ApplicationProvider import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.maps.android.data.Layer +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -64,8 +68,8 @@ class KmlLayerOnMapTest { @Test fun addLayerToMap_rendersPointsLinesAndPolygons() { - io.mockk.mockkStatic(com.google.android.gms.maps.model.BitmapDescriptorFactory::class) - io.mockk.every { com.google.android.gms.maps.model.BitmapDescriptorFactory.defaultMarker(any()) } returns io.mockk.mockk() + mockkStatic(BitmapDescriptorFactory::class) + every { BitmapDescriptorFactory.defaultMarker(any()) } returns mockk() try { val kml = """ @@ -92,7 +96,7 @@ class KmlLayerOnMapTest { layer.addLayerToMap() assertTrue(layer.isLayerOnMap()) } finally { - io.mockk.unmockkStatic(com.google.android.gms.maps.model.BitmapDescriptorFactory::class) + unmockkStatic(BitmapDescriptorFactory::class) } } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt index 7ead8b924..24fe315fd 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ZIndexDemoActivity.kt @@ -232,26 +232,21 @@ data class MarkerShape( * see the official [Google Maps Markers Documentation](https://developers.google.com/maps/documentation/android-sdk/marker#z-index). * * Key API calls: - * - [com.google.android.gms.maps.model.Polygon.setZIndex] - * - [com.google.android.gms.maps.model.Polyline.setZIndex] - * - [com.google.android.gms.maps.model.Marker.setZIndex] - * - [com.google.android.gms.maps.GoogleMap.addPolygon] - * - [com.google.android.gms.maps.GoogleMap.addPolyline] - * - [com.google.android.gms.maps.GoogleMap.addMarker] + * - [Polygon.setZIndex] + * - [Polyline.setZIndex] + * - [Marker.setZIndex] + * - [GoogleMap.addPolygon] + * - [GoogleMap.addPolyline] + * - [GoogleMap.addMarker] */ class ZIndexDemoActivity : BaseDemoActivity() { private lateinit var binding: ActivityZindexDemoBinding - /** - * Registry of all active demo shapes, keyed by their unique shape ID. - * When a shape's z-index is modified, the updated shape replaces the previous entry here. - */ + // Registry of all active demo shapes, keyed by unique shape ID private val shapes = LinkedHashMap() - /** - * Binds a shape's ID to its corresponding UI row widgets. - */ + // Binds a shape's ID to its corresponding UI row widgets private data class ShapeRowBinding( val shapeId: String, val minusButton: View, @@ -301,9 +296,7 @@ class ZIndexDemoActivity : BaseDemoActivity() { setupControls() } - /** - * Initializes the [MapShape] instances, draws them on the [map], and populates [shapes]. - */ + // Initializes the demo shapes, draws them on the map, and populates the registry private fun setupFeatures(map: GoogleMap, center: LatLng) { val lat = center.latitude val lng = center.longitude @@ -384,9 +377,7 @@ class ZIndexDemoActivity : BaseDemoActivity() { } } - /** - * Binds stepper buttons and initial z-index labels for each shape. - */ + // Binds stepper buttons and initial z-index labels for each shape private fun setupControls() { for (row in rowBindings) { val shape = shapes[row.shapeId] ?: continue @@ -398,10 +389,7 @@ class ZIndexDemoActivity : BaseDemoActivity() { binding.infoButton.setOnClickListener { showInfoDialog() } } - /** - * Displays an informative pop-up dialog explaining the sample's purpose, expected behavior, - * marker z-index grouping, and tie-breaking rules with links to official documentation. - */ + // Displays an informative pop-up dialog explaining z-index grouping and tie-breaking rules private fun showInfoDialog() { val messageHtml = HtmlCompat.fromHtml( getString(R.string.zindex_dialog_message), @@ -418,9 +406,7 @@ class ZIndexDemoActivity : BaseDemoActivity() { LinkMovementMethod.getInstance() } - /** - * Resets all shapes to their initial default z-index values and updates the map in-place. - */ + // Resets all shapes to their initial default z-index values in-place private fun resetZIndices() { val map = map ?: return for ((shapeId, defaultZIndex) in INITIAL_Z_INDICES) { @@ -434,10 +420,7 @@ class ZIndexDemoActivity : BaseDemoActivity() { } // [START maps_android_utils_zindex_update_sample] - /** - * Updates the z-index of the shape identified by [shapeId] by [delta]. - * Refreshes the shape on the map in-place and replaces the entry in [shapes]. - */ + // Updates the shape's z-index by delta and refreshes the map in-place private fun updateShapeZIndex(shapeId: String, delta: Float) { val currentShape = shapes[shapeId] ?: return val map = map ?: return