From 25e4f6acbf521bd253f25d26b0e2b81ccf829d7c Mon Sep 17 00:00:00 2001 From: Adrian Cojocaru Date: Tue, 4 Aug 2026 19:21:25 +0300 Subject: [PATCH] fix(android): Add option to move symbol location indicator bearing down the image stack (#4408) --- .../location/LocationComponentOptions.java | 39 ++++++++++ .../LocationComponentPositionManager.java | 12 +++- .../location/LocationLayerController.java | 5 +- .../location/SymbolLocationLayerRenderer.java | 30 +++++--- .../src/main/res/values/attrs.xml | 1 + .../LocationComponentPositionManagerTest.kt | 71 ++++++++++++------- .../location/LocationLayerControllerTest.kt | 16 ++--- 7 files changed, 128 insertions(+), 46 deletions(-) diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java index 83b6db8a2..76a639b42 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java @@ -133,6 +133,7 @@ public class LocationComponentOptions implements Parcelable { private RectF trackingMultiFingerProtectedMoveArea; private String layerAbove; private String layerBelow; + private boolean bearingOnTop; private float trackingAnimationDurationMultiplier; private boolean compassAnimationEnabled; private boolean accuracyAnimationEnabled; @@ -177,6 +178,7 @@ public class LocationComponentOptions implements Parcelable { RectF trackingMultiFingerProtectedMoveArea, String layerAbove, String layerBelow, + boolean bearingOnTop, float trackingAnimationDurationMultiplier, boolean compassAnimationEnabled, boolean accuracyAnimationEnabled, @@ -221,6 +223,7 @@ public class LocationComponentOptions implements Parcelable { this.trackingMultiFingerProtectedMoveArea = trackingMultiFingerProtectedMoveArea; this.layerAbove = layerAbove; this.layerBelow = layerBelow; + this.bearingOnTop = bearingOnTop; this.trackingAnimationDurationMultiplier = trackingAnimationDurationMultiplier; this.compassAnimationEnabled = compassAnimationEnabled; this.accuracyAnimationEnabled = accuracyAnimationEnabled; @@ -327,6 +330,9 @@ public class LocationComponentOptions implements Parcelable { builder.layerBelow( typedArray.getString(R.styleable.maplibre_LocationComponent_maplibre_layer_below)); + builder.bearingOnTop( + typedArray.getBoolean(R.styleable.maplibre_LocationComponent_maplibre_bearing_on_top, true)); + float minScale = typedArray.getFloat( R.styleable.maplibre_LocationComponent_maplibre_minZoomIconScale, MIN_ZOOM_ICON_SCALE_DEFAULT); float maxScale = typedArray.getFloat( @@ -798,6 +804,15 @@ public class LocationComponentOptions implements Parcelable { return layerBelow; } + /** + * Whether the bearing icon is rendered on top of the foreground icon in the location layer stack. + * + * @return true if the bearing icon is rendered above the foreground icon, false otherwise. + */ + public boolean bearingOnTop() { + return bearingOnTop; + } + /** * Get the tracking animation duration multiplier. * @@ -927,6 +942,7 @@ public class LocationComponentOptions implements Parcelable { + "trackingMultiFingerProtectedMoveArea=" + trackingMultiFingerProtectedMoveArea + ", " + "layerAbove=" + layerAbove + "layerBelow=" + layerBelow + + "bearingOnTop=" + bearingOnTop + "trackingAnimationDurationMultiplier=" + trackingAnimationDurationMultiplier + "pulseEnabled=" + pulseEnabled + "pulseFadeEnabled=" + pulseFadeEnabled @@ -1057,6 +1073,10 @@ public class LocationComponentOptions implements Parcelable { return false; } + if (bearingOnTop != options.bearingOnTop) { + return false; + } + if (pulseEnabled != options.pulseEnabled) { return false; } @@ -1120,6 +1140,7 @@ public class LocationComponentOptions implements Parcelable { ? trackingMultiFingerProtectedMoveArea.hashCode() : 0); result = 31 * result + (layerAbove != null ? layerAbove.hashCode() : 0); result = 31 * result + (layerBelow != null ? layerBelow.hashCode() : 0); + result = 31 * result + (bearingOnTop ? 1 : 0); result = 31 * result + (trackingAnimationDurationMultiplier != +0.0f ? Float.floatToIntBits(trackingAnimationDurationMultiplier) : 0); result = 31 * result + (compassAnimationEnabled ? 1 : 0); @@ -1171,6 +1192,7 @@ public class LocationComponentOptions implements Parcelable { dest.writeParcelable(this.trackingMultiFingerProtectedMoveArea, flags); dest.writeString(this.layerAbove); dest.writeString(this.layerBelow); + dest.writeByte(this.bearingOnTop ? (byte) 1 : (byte) 0); dest.writeFloat(this.trackingAnimationDurationMultiplier); dest.writeByte(this.compassAnimationEnabled ? (byte) 1 : (byte) 0); dest.writeByte(this.accuracyAnimationEnabled ? (byte) 1 : (byte) 0); @@ -1214,6 +1236,7 @@ public class LocationComponentOptions implements Parcelable { this.trackingMultiFingerProtectedMoveArea = in.readParcelable(RectF.class.getClassLoader()); this.layerAbove = in.readString(); this.layerBelow = in.readString(); + this.bearingOnTop = in.readByte() != 0; this.trackingAnimationDurationMultiplier = in.readFloat(); this.compassAnimationEnabled = in.readByte() != 0; this.accuracyAnimationEnabled = in.readByte() != 0; @@ -1338,6 +1361,7 @@ public class LocationComponentOptions implements Parcelable { private RectF trackingMultiFingerProtectedMoveArea; private String layerAbove; private String layerBelow; + private boolean bearingOnTop; private Float trackingAnimationDurationMultiplier; private Boolean compassAnimationEnabled; private Boolean accuracyAnimationEnabled; @@ -1385,6 +1409,7 @@ public class LocationComponentOptions implements Parcelable { this.trackingMultiFingerProtectedMoveArea = source.trackingMultiFingerProtectedMoveArea(); this.layerAbove = source.layerAbove(); this.layerBelow = source.layerBelow(); + this.bearingOnTop = source.bearingOnTop(); this.trackingAnimationDurationMultiplier = source.trackingAnimationDurationMultiplier(); this.compassAnimationEnabled = source.compassAnimationEnabled(); this.accuracyAnimationEnabled = source.accuracyAnimationEnabled(); @@ -1866,6 +1891,19 @@ public class LocationComponentOptions implements Parcelable { return this; } + /** + * Sets whether the bearing icon is rendered on top of the foreground icon in the location + * layer stack. When true, the bearing layer is placed above the foreground layer; when false, + * it is rendered below. + * + * @param bearingOnTop true to render the bearing icon above the foreground icon, false for below. + */ + @NonNull + public LocationComponentOptions.Builder bearingOnTop(boolean bearingOnTop) { + this.bearingOnTop = bearingOnTop; + return this; + } + /** * Sets the tracking animation duration multiplier. * @@ -2065,6 +2103,7 @@ public class LocationComponentOptions implements Parcelable { this.trackingMultiFingerProtectedMoveArea, this.layerAbove, this.layerBelow, + this.bearingOnTop, this.trackingAnimationDurationMultiplier, this.compassAnimationEnabled, this.accuracyAnimationEnabled, diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentPositionManager.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentPositionManager.java index f0a3185dd..dd5f25349 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentPositionManager.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentPositionManager.java @@ -17,22 +17,28 @@ class LocationComponentPositionManager { @Nullable private String layerBelow; - LocationComponentPositionManager(@NonNull Style style, @Nullable String layerAbove, @Nullable String layerBelow) { + public boolean bearingOnTop; + + LocationComponentPositionManager(@NonNull Style style, @Nullable String layerAbove, + @Nullable String layerBelow, Boolean bearingOnTop) { this.style = style; this.layerAbove = layerAbove; this.layerBelow = layerBelow; + this.bearingOnTop = bearingOnTop; } /** * Returns true whenever layer above/below configuration has changed and requires re-layout. */ - boolean update(@Nullable String layerAbove, @Nullable String layerBelow) { + boolean update(@Nullable String layerAbove, @Nullable String layerBelow, boolean bearingOnTop) { boolean requiresUpdate = !(this.layerAbove == layerAbove || (this.layerAbove != null && this.layerAbove.equals(layerAbove))) - || !(this.layerBelow == layerBelow || (this.layerBelow != null && this.layerBelow.equals(layerBelow))); + || !(this.layerBelow == layerBelow || (this.layerBelow != null && this.layerBelow.equals(layerBelow))) + || (this.bearingOnTop != bearingOnTop); this.layerAbove = layerAbove; this.layerBelow = layerBelow; + this.bearingOnTop = bearingOnTop; return requiresUpdate; } diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationLayerController.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationLayerController.java index a1039c12e..a17fd617b 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationLayerController.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationLayerController.java @@ -74,7 +74,8 @@ final class LocationLayerController { } void initializeComponents(Style style, LocationComponentOptions options) { - this.positionManager = new LocationComponentPositionManager(style, options.layerAbove(), options.layerBelow()); + this.positionManager = new LocationComponentPositionManager(style, options.layerAbove(), + options.layerBelow(), options.bearingOnTop()); locationLayerRenderer.initializeComponents(style); locationLayerRenderer.addLayers(positionManager); applyStyle(options); @@ -87,7 +88,7 @@ final class LocationLayerController { } void applyStyle(@NonNull LocationComponentOptions options) { - if (positionManager.update(options.layerAbove(), options.layerBelow())) { + if (positionManager.update(options.layerAbove(), options.layerBelow(), options.bearingOnTop())) { locationLayerRenderer.removeLayers(); locationLayerRenderer.addLayers(positionManager); if (isHidden) { diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/SymbolLocationLayerRenderer.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/SymbolLocationLayerRenderer.java index a647b7534..8ec6601cd 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/SymbolLocationLayerRenderer.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/SymbolLocationLayerRenderer.java @@ -83,15 +83,29 @@ final class SymbolLocationLayerRenderer implements LocationLayerRenderer { @Override public void addLayers(LocationComponentPositionManager positionManager) { - // positions the top-most reference layer - Layer layer = layerSourceProvider.generateLayer(BEARING_LAYER); - positionManager.addLayerToMap(layer); - layerSet.add(layer.getId()); - // adds remaining layers while keeping the order - addSymbolLayer(FOREGROUND_LAYER, BEARING_LAYER); - addSymbolLayer(BACKGROUND_LAYER, FOREGROUND_LAYER); - addSymbolLayer(SHADOW_LAYER, BACKGROUND_LAYER); + if (positionManager.bearingOnTop) { + // positions the top-most reference layer + Layer layer = layerSourceProvider.generateLayer(BEARING_LAYER); + positionManager.addLayerToMap(layer); + layerSet.add(layer.getId()); + + // adds remaining layers while keeping the order + addSymbolLayer(FOREGROUND_LAYER, BEARING_LAYER); + addSymbolLayer(BACKGROUND_LAYER, FOREGROUND_LAYER); + addSymbolLayer(SHADOW_LAYER, BACKGROUND_LAYER); + } else { + // positions the top-most reference layer + Layer layer = layerSourceProvider.generateLayer(FOREGROUND_LAYER); + positionManager.addLayerToMap(layer); + layerSet.add(layer.getId()); + + // adds remaining layers while keeping the order + addSymbolLayer(BACKGROUND_LAYER, FOREGROUND_LAYER); + addSymbolLayer(BEARING_LAYER, BACKGROUND_LAYER); + addSymbolLayer(SHADOW_LAYER, BEARING_LAYER); + } + addAccuracyLayer(); addPulsingCircleLayerToMap(); } diff --git a/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml b/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml index 622e18640..78a822286 100644 --- a/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml +++ b/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml @@ -181,6 +181,7 @@ + diff --git a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationComponentPositionManagerTest.kt b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationComponentPositionManagerTest.kt index 1b18d9dcb..9b023a247 100644 --- a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationComponentPositionManagerTest.kt +++ b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationComponentPositionManagerTest.kt @@ -26,70 +26,91 @@ class LocationComponentPositionManagerTest : BaseTest() { @Test fun update_noChange_null() { - val positionManager = LocationComponentPositionManager(style, null, null) - val requiresUpdate = positionManager.update(null, null) + val positionManager = LocationComponentPositionManager(style, null, null, false) + val requiresUpdate = positionManager.update(null, null, false) assertFalse(requiresUpdate) } @Test fun update_noChange_above() { - val positionManager = LocationComponentPositionManager(style, "above", null) - val requiresUpdate = positionManager.update("above", null) + val positionManager = LocationComponentPositionManager(style, "above", null, false) + val requiresUpdate = positionManager.update("above", null, false) assertFalse(requiresUpdate) } @Test fun update_noChange_below() { - val positionManager = LocationComponentPositionManager(style, null, "below") - val requiresUpdate = positionManager.update(null, "below") + val positionManager = LocationComponentPositionManager(style, null, "below", false) + val requiresUpdate = positionManager.update(null, "below", false) + assertFalse(requiresUpdate) + } + + @Test + fun update_noChange_bearingOnTop() { + val positionManager = LocationComponentPositionManager(style, null, null, true) + val requiresUpdate = positionManager.update(null, null, true) assertFalse(requiresUpdate) } @Test fun update_fromNull_above() { - val positionManager = LocationComponentPositionManager(style, null, null) - val requiresUpdate = positionManager.update("above", null) + val positionManager = LocationComponentPositionManager(style, null, null, false) + val requiresUpdate = positionManager.update("above", null, false) assertTrue(requiresUpdate) } @Test fun update_fromNull_below() { - val positionManager = LocationComponentPositionManager(style, null, null) - val requiresUpdate = positionManager.update(null, "below") + val positionManager = LocationComponentPositionManager(style, null, null, false) + val requiresUpdate = positionManager.update(null, "below", false) assertTrue(requiresUpdate) } @Test fun update_toNull_above() { - val positionManager = LocationComponentPositionManager(style, "above", null) - val requiresUpdate = positionManager.update(null, null) + val positionManager = LocationComponentPositionManager(style, "above", null, false) + val requiresUpdate = positionManager.update(null, null, false) assertTrue(requiresUpdate) } @Test fun update_toNull_below() { - val positionManager = LocationComponentPositionManager(style, null, "below") - val requiresUpdate = positionManager.update(null, null) + val positionManager = LocationComponentPositionManager(style, null, "below", false) + val requiresUpdate = positionManager.update(null, null, false) assertTrue(requiresUpdate) } @Test fun update_fromValue_above() { - val positionManager = LocationComponentPositionManager(style, "above1", null) - val requiresUpdate = positionManager.update("above2", null) + val positionManager = LocationComponentPositionManager(style, "above1", null, false) + val requiresUpdate = positionManager.update("above2", null, false) assertTrue(requiresUpdate) } @Test fun update_fromValue_below() { - val positionManager = LocationComponentPositionManager(style, null, "below1") - val requiresUpdate = positionManager.update(null, "below2") + val positionManager = LocationComponentPositionManager(style, null, "below1", false) + val requiresUpdate = positionManager.update(null, "below2", false) + assertTrue(requiresUpdate) + } + + @Test + fun update_bearingOnTop_changed() { + val positionManager = LocationComponentPositionManager(style, null, null, false) + val requiresUpdate = positionManager.update(null, null, true) + assertTrue(requiresUpdate) + } + + @Test + fun update_bearingOnTop_changedToFalse() { + val positionManager = LocationComponentPositionManager(style, null, null, true) + val requiresUpdate = positionManager.update(null, null, false) assertTrue(requiresUpdate) } @Test fun addLayer_noModifier() { - val positionManager = LocationComponentPositionManager(style, null, null) + val positionManager = LocationComponentPositionManager(style, null, null, false) positionManager.addLayerToMap(layer) verify { style.addLayer(layer) } @@ -97,7 +118,7 @@ class LocationComponentPositionManagerTest : BaseTest() { @Test fun addLayer_above() { - val positionManager = LocationComponentPositionManager(style, "above", null) + val positionManager = LocationComponentPositionManager(style, "above", null, false) positionManager.addLayerToMap(layer) verify { style.addLayerAbove(layer, "above") } @@ -105,7 +126,7 @@ class LocationComponentPositionManagerTest : BaseTest() { @Test fun addLayer_below() { - val positionManager = LocationComponentPositionManager(style, null, "below") + val positionManager = LocationComponentPositionManager(style, null, "below", false) positionManager.addLayerToMap(layer) verify { style.addLayerBelow(layer, "below") } @@ -113,8 +134,8 @@ class LocationComponentPositionManagerTest : BaseTest() { @Test fun addLayer_afterUpdate_above() { - val positionManager = LocationComponentPositionManager(style, null, null) - positionManager.update("above", null) + val positionManager = LocationComponentPositionManager(style, null, null, false) + positionManager.update("above", null, false) positionManager.addLayerToMap(layer) verify { style.addLayerAbove(layer, "above") } @@ -122,8 +143,8 @@ class LocationComponentPositionManagerTest : BaseTest() { @Test fun addLayer_afterUpdate_below() { - val positionManager = LocationComponentPositionManager(style, null, null) - positionManager.update(null, "below") + val positionManager = LocationComponentPositionManager(style, null, null, false) + positionManager.update(null, "below", false) positionManager.addLayerToMap(layer) verify { style.addLayerBelow(layer, "below") } diff --git a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationLayerControllerTest.kt b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationLayerControllerTest.kt index 229e1325e..e77f73d20 100644 --- a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationLayerControllerTest.kt +++ b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/location/LocationLayerControllerTest.kt @@ -102,7 +102,7 @@ class LocationLayerControllerTest : BaseTest() { false ) Mockito.verify(style) - .addLayerBelow(shadowLayer, LocationComponentConstants.BACKGROUND_LAYER) + .addLayerBelow(shadowLayer, LocationComponentConstants.BEARING_LAYER) } @Test @@ -181,8 +181,7 @@ class LocationLayerControllerTest : BaseTest() { internalRenderModeChangedListener, false ) - Mockito.verify(style) - .addLayerBelow(foregroundLayer, LocationComponentConstants.BEARING_LAYER) + Mockito.verify(style).addLayer(foregroundLayer) } @Test @@ -223,7 +222,8 @@ class LocationLayerControllerTest : BaseTest() { internalRenderModeChangedListener, false ) - Mockito.verify(style).addLayerBelow(bearingLayer, layerBelow) + Mockito.verify(style) + .addLayerBelow(bearingLayer, LocationComponentConstants.BACKGROUND_LAYER) } @Test @@ -757,13 +757,13 @@ class LocationLayerControllerTest : BaseTest() { Mockito.verify(style).removeLayer(LocationComponentConstants.BACKGROUND_LAYER) Mockito.verify(style).removeLayer(LocationComponentConstants.SHADOW_LAYER) Mockito.verify(style).removeLayer(LocationComponentConstants.ACCURACY_LAYER) - Mockito.verify(style).addLayerBelow(bearingLayer2, layerBelow) - Mockito.verify(style) - .addLayerBelow(foregroundLayer2, LocationComponentConstants.BEARING_LAYER) + Mockito.verify(style).addLayerBelow(foregroundLayer2, layerBelow) Mockito.verify(style) .addLayerBelow(backgroundLayer2, LocationComponentConstants.FOREGROUND_LAYER) Mockito.verify(style) - .addLayerBelow(shadowLayer2, LocationComponentConstants.BACKGROUND_LAYER) + .addLayerBelow(bearingLayer2, LocationComponentConstants.BACKGROUND_LAYER) + Mockito.verify(style) + .addLayerBelow(shadowLayer2, LocationComponentConstants.BEARING_LAYER) Mockito.verify(style) .addLayerBelow(accuracyLayer2, LocationComponentConstants.BACKGROUND_LAYER) }