Skip to content

Commit 8a7ed6d

Browse files
pengdevgithub-actions[bot]
authored andcommitted
[maps-sdk] Fix view annotations not rendering under RTL layoutDirection (#16540)
GitOrigin-RevId: 58f02afe75d8c615da8f817bc3a9b845846055a2
1 parent fbb31cf commit 8a7ed6d

9 files changed

Lines changed: 177 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Mapbox welcomes participation and contributions from everyone.
99
* Expand the minimum camera field of view from 11 to 1 degrees.
1010
* Introduce `LineLayer.lineBorderGradient` API to color the border of a line feature with a gradient along its length. Takes precedence over `lineBorderColor` and requires `lineBorderWidth` to be greater than zero.
1111

12+
## Bug fixes 🐞
13+
* Fix view annotations rendering incorrectly or not appearing when the host app's layout direction is RTL.
1214

1315
# 11.27.0 July 24, 2026
1416
## Features ✨ and improvements 🏁

app/src/main/java/com/mapbox/maps/testapp/examples/markersandcallouts/viewannotation/DynamicViewAnnotationActivity.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ import kotlinx.coroutines.withContext
8989
* annotations([ViewAnnotationOptions.allowOverlap]=true), and allow overlapping with the location
9090
* puck([ViewAnnotationOptions.allowOverlapWithPuck]=true). This is useful for high priority labels
9191
* that shouldn't be covered by anything.
92+
*
93+
* The "Layout direction" button toggles the MapView's [View.layoutDirection] between LTR and RTL,
94+
* to check that view annotations (in particular the ETA / alternative ETA callouts) remain visible
95+
* and correctly positioned regardless of the host view's layout direction.
9296
*/
9397
class DynamicViewAnnotationActivity : AppCompatActivity() {
9498

@@ -126,6 +130,7 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
126130

127131
private var isMainActive = true
128132
private var isOverview = true
133+
private var isLayoutDirectionRtl = false
129134

130135
override fun onCreate(savedInstanceState: Bundle?) {
131136
super.onCreate(savedInstanceState)
@@ -154,6 +159,25 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
154159
}
155160
}
156161

162+
binding.btnLayoutDirection.setOnClickListener {
163+
isLayoutDirectionRtl = !isLayoutDirectionRtl
164+
val direction = if (isLayoutDirectionRtl) {
165+
View.LAYOUT_DIRECTION_RTL
166+
} else {
167+
View.LAYOUT_DIRECTION_LTR
168+
}
169+
binding.mapView.layoutDirection = direction
170+
// ViewAnnotationManagerImpl always resolves annotation content against
171+
// View.LAYOUT_DIRECTION_LOCALE, which is what a real RTL system locale would produce.
172+
// This device's locale is LTR, so mirror that resolved value directly on the existing
173+
// DVAs here to verify the same mechanism (content mirrors, position stays correct because
174+
// it's governed entirely by the parent viewAnnotationsLayout, not by this).
175+
if (::etaView.isInitialized) etaView.layoutDirection = direction
176+
if (::alternativeEtaView.isInitialized) alternativeEtaView.layoutDirection = direction
177+
refreshLayoutDirectionButton(binding.btnLayoutDirection)
178+
}
179+
refreshLayoutDirectionButton(binding.btnLayoutDirection)
180+
157181
loadAssets()
158182

159183
mapView.mapboxMap.apply {
@@ -404,6 +428,13 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
404428
btnMode.text = getString(R.string.dynamic_mode, if (isOverview) "follow" else "overview")
405429
}
406430

431+
private fun refreshLayoutDirectionButton(btnLayoutDirection: Button) {
432+
btnLayoutDirection.text = getString(
433+
R.string.dynamic_layout_direction,
434+
if (isLayoutDirectionRtl) "RTL" else "LTR"
435+
)
436+
}
437+
407438
@OptIn(com.mapbox.maps.MapboxExperimental::class)
408439
private fun addViewAnnotations() {
409440
etaView = viewAnnotationManager.addViewAnnotation(

app/src/main/res/layout/activity_dynamic_view_annotations.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,17 @@
2323
tools:text="Mode : overview"
2424
tools:backgroundTint="@color/blue" />
2525

26+
<androidx.appcompat.widget.AppCompatButton
27+
android:id="@+id/btn_layout_direction"
28+
android:layout_width="wrap_content"
29+
android:layout_height="wrap_content"
30+
android:layout_margin="16dp"
31+
android:textColor="@color/white"
32+
android:backgroundTint="@color/mapbox_blue"
33+
app:layout_constraintBottom_toTopOf="@id/btn_mode"
34+
app:layout_constraintEnd_toEndOf="parent"
35+
app:layout_constraintStart_toStartOf="parent"
36+
tools:text="Layout direction : LTR"
37+
tools:backgroundTint="@color/blue" />
38+
2639
</androidx.constraintlayout.widget.ConstraintLayout>

app/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
<!--View Annotations-->
172172
<string name="content_description_close">Close callout</string>
173173
<string name="dynamic_mode">Mode: %1$s</string>
174+
<string name="dynamic_layout_direction">Layout direction: %1$s</string>
174175
<string name="collision_debug">Collision debug</string>
175176
<string name="va_symbol_collision">Enable ViewAnnotation-Symbols collision</string>
176177
<string name="va_allow_overlap">Allow Overlap (view annotations)</string>

compose-app/src/main/java/com/mapbox/maps/compose/testapp/examples/annotation/DynamicViewAnnotationActivity.kt

Lines changed: 93 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.compose.foundation.Image
99
import androidx.compose.foundation.background
1010
import androidx.compose.foundation.clickable
1111
import androidx.compose.foundation.interaction.MutableInteractionSource
12+
import androidx.compose.foundation.layout.Arrangement
1213
import androidx.compose.foundation.layout.Box
1314
import androidx.compose.foundation.layout.Column
1415
import androidx.compose.foundation.layout.Row
@@ -24,6 +25,7 @@ import androidx.compose.material.FloatingActionButton
2425
import androidx.compose.material.MaterialTheme
2526
import androidx.compose.material.Text
2627
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.CompositionLocalProvider
2729
import androidx.compose.runtime.getValue
2830
import androidx.compose.runtime.key
2931
import androidx.compose.runtime.mutableStateOf
@@ -44,6 +46,7 @@ import androidx.compose.ui.graphics.Outline
4446
import androidx.compose.ui.graphics.Path
4547
import androidx.compose.ui.graphics.Shape
4648
import androidx.compose.ui.graphics.asAndroidPath
49+
import androidx.compose.ui.platform.LocalLayoutDirection
4750
import androidx.compose.ui.res.painterResource
4851
import androidx.compose.ui.text.style.TextAlign
4952
import androidx.compose.ui.tooling.preview.Preview
@@ -191,59 +194,86 @@ public class DynamicViewAnnotationActivity : ComponentActivity() {
191194
mutableStateOf(true)
192195
}
193196

197+
// Toggles the MapView's own layoutDirection between LTR/RTL to verify Compose-based
198+
// view annotations remain visible and correctly positioned regardless of host layout
199+
// direction (see ViewAnnotationManagerImpl - annotation views default to
200+
// Gravity.START|TOP, which must resolve against a container pinned to LTR).
201+
var isLayoutDirectionRtl by remember {
202+
mutableStateOf(false)
203+
}
204+
194205
MapboxMapComposeTheme {
195206
ExampleScaffold(
196207
floatingActionButton = {
197-
FloatingActionButton(
198-
onClick = {
199-
if ((viewportState.mapViewportStatus as? ViewportStatus.State)?.state is FollowPuckViewportState) {
200-
viewportState.transitionToOverviewState(overviewViewportStateOptions)
201-
} else {
202-
viewportState.transitionToFollowPuckState(followPuckViewportStateOption)
203-
}
204-
},
205-
shape = RoundedCornerShape(16.dp),
208+
Column(
209+
horizontalAlignment = Alignment.End,
210+
verticalArrangement = Arrangement.spacedBy(8.dp)
206211
) {
207-
Text(
208-
modifier = Modifier.padding(10.dp),
209-
text = if ((viewportState.mapViewportStatus as? ViewportStatus.State)?.state is FollowPuckViewportState) "Overview" else "Follow puck"
210-
)
212+
FloatingActionButton(
213+
onClick = { isLayoutDirectionRtl = !isLayoutDirectionRtl },
214+
shape = RoundedCornerShape(16.dp),
215+
) {
216+
Text(
217+
modifier = Modifier.padding(10.dp),
218+
text = "Layout direction: ${if (isLayoutDirectionRtl) "RTL" else "LTR"}"
219+
)
220+
}
221+
FloatingActionButton(
222+
onClick = {
223+
if ((viewportState.mapViewportStatus as? ViewportStatus.State)?.state is FollowPuckViewportState) {
224+
viewportState.transitionToOverviewState(overviewViewportStateOptions)
225+
} else {
226+
viewportState.transitionToFollowPuckState(followPuckViewportStateOption)
227+
}
228+
},
229+
shape = RoundedCornerShape(16.dp),
230+
) {
231+
Text(
232+
modifier = Modifier.padding(10.dp),
233+
text = if ((viewportState.mapViewportStatus as? ViewportStatus.State)?.state is FollowPuckViewportState) "Overview" else "Follow puck"
234+
)
235+
}
211236
}
212237
}
213238
) {
214-
MapboxMap(
215-
Modifier.fillMaxSize(),
216-
mapViewportState = viewportState,
217-
style = {
218-
NavigationStyle(isMainActive)
219-
}
239+
CompositionLocalProvider(
240+
LocalLayoutDirection provides
241+
if (isLayoutDirectionRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
220242
) {
221-
MapEffect(Unit) { mapView ->
222-
mapView.location.apply {
223-
setLocationProvider(
224-
SimulateRouteLocationProvider(
225-
featureRouteMain.geometry() as LineString
226-
)
227-
)
228-
locationPuck = LocationPuck2D(
229-
bearingImage = ImageHolder.from(R.drawable.mapbox_user_puck_icon),
230-
)
231-
enabled = true
232-
puckBearingEnabled = true
233-
puckBearing = PuckBearing.COURSE
243+
MapboxMap(
244+
Modifier.fillMaxSize(),
245+
mapViewportState = viewportState,
246+
style = {
247+
NavigationStyle(isMainActive)
234248
}
235-
// Only show view annotation and adjust location puck position after all the runtime layers
236-
// are added.
237-
mapView.mapboxMap.mapLoadedEvents.firstOrNull()?.let {
238-
mapView.location.layerAbove = LAYER_CONSTRUCTION
239-
showDynamicViewAnnotations = true
240-
viewportState.transitionToOverviewState(overviewViewportStateOptions)
249+
) {
250+
MapEffect(Unit) { mapView ->
251+
mapView.location.apply {
252+
setLocationProvider(
253+
SimulateRouteLocationProvider(
254+
featureRouteMain.geometry() as LineString
255+
)
256+
)
257+
locationPuck = LocationPuck2D(
258+
bearingImage = ImageHolder.from(R.drawable.mapbox_user_puck_icon),
259+
)
260+
enabled = true
261+
puckBearingEnabled = true
262+
puckBearing = PuckBearing.COURSE
263+
}
264+
// Only show view annotation and adjust location puck position after all the runtime layers
265+
// are added.
266+
mapView.mapboxMap.mapLoadedEvents.firstOrNull()?.let {
267+
mapView.location.layerAbove = LAYER_CONSTRUCTION
268+
showDynamicViewAnnotations = true
269+
viewportState.transitionToOverviewState(overviewViewportStateOptions)
270+
}
241271
}
242-
}
243272

244-
if (showDynamicViewAnnotations) {
245-
DynamicViewAnnotations(isMainActive) {
246-
isMainActive = !isMainActive
273+
if (showDynamicViewAnnotations) {
274+
DynamicViewAnnotations(isMainActive, isLayoutDirectionRtl) {
275+
isMainActive = !isMainActive
276+
}
247277
}
248278
}
249279
}
@@ -314,7 +344,11 @@ public class DynamicViewAnnotationActivity : ComponentActivity() {
314344

315345
@Composable
316346
@MapboxMapComposable
317-
private fun DynamicViewAnnotations(isMainActive: Boolean, toggleActiveRoute: () -> Unit) {
347+
private fun DynamicViewAnnotations(
348+
isMainActive: Boolean,
349+
isLayoutDirectionRtl: Boolean,
350+
toggleActiveRoute: () -> Unit
351+
) {
318352
var etaViewAnnotationAnchor by remember {
319353
mutableStateOf(ViewAnnotationAnchor.BOTTOM_LEFT)
320354
}
@@ -375,11 +409,22 @@ public class DynamicViewAnnotationActivity : ComponentActivity() {
375409
}
376410
}
377411
) {
378-
AlternativeDVAContent(
379-
isInitial = !isMainActive,
380-
alternativeEtaViewAnnotationAnchor = alternativeEtaViewAnnotationAnchor,
381-
onClick = toggleActiveRoute
382-
)
412+
// ViewAnnotation hosts this content in its own ComposeView (see ViewAnnotation.kt), which
413+
// maps-sdk pins to LAYOUT_DIRECTION_LOCALE - the nearest LocalLayoutDirection provider to
414+
// this content, so it resolves against the real device locale regardless of any
415+
// CompositionLocalProvider wrapped further up around the outer MapboxMap composable.
416+
// Override LocalLayoutDirection directly here (Compose's nearest-provider-wins resolution)
417+
// to verify content mirroring without changing the device's actual locale.
418+
CompositionLocalProvider(
419+
LocalLayoutDirection provides
420+
if (isLayoutDirectionRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
421+
) {
422+
AlternativeDVAContent(
423+
isInitial = !isMainActive,
424+
alternativeEtaViewAnnotationAnchor = alternativeEtaViewAnnotationAnchor,
425+
onClick = toggleActiveRoute
426+
)
427+
}
383428
}
384429

385430
// parking view annotation 1

maps-sdk/src/main/java/com/mapbox/maps/viewannotation/ViewAnnotationManager.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ import com.mapbox.maps.ViewAnnotationOptions
2424
* could be controlled by the user using update operation.
2525
*
2626
* View annotation manager instance is destroyed automatically when [MapView.onDestroy] is called.
27+
*
28+
* View annotations are always positioned assuming a left-to-right coordinate system, regardless
29+
* of the host app's locale or the [MapView]'s own layout direction — this keeps positioning
30+
* correct and predictable everywhere. Independent of that, each annotation view's own content
31+
* (for example `Gravity.START`/`END`, `marginStart`/`marginEnd`) is automatically resolved
32+
* against the device's actual system locale, so RTL languages continue to mirror correctly with
33+
* no extra setup required.
2734
*/
2835
interface ViewAnnotationManager {
2936

maps-sdk/src/main/java/com/mapbox/maps/viewannotation/ViewAnnotationManagerImpl.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ internal class ViewAnnotationManagerImpl(
9999
ViewGroup.LayoutParams.MATCH_PARENT,
100100
)
101101
clipChildren = false // Prevents view annotation shadows or bouncing animation clipping.
102+
// Annotation views get an unset (default) Gravity, which FrameLayout resolves to
103+
// Gravity.START|TOP; positionAnnotationViews() then applies translationX/Y computed by
104+
// gl-native (via onDelegatingViewAnnotationPositionsUpdate) assuming a left-anchored (x=0)
105+
// base position. If this layout inherits RTL from MapView, START mirrors to the right edge,
106+
// so the same translationX lands on the wrong side (or fully off-screen). Pin LTR so the
107+
// base position always matches gl-native's left-anchored coordinate system, regardless of
108+
// the host app's locale/layoutDirection.
109+
layoutDirection = View.LAYOUT_DIRECTION_LTR
102110
}
103111
// place the view annotations above the map (index 0) but below the compass, ruler and other plugin views
104112
mapView.addView(viewAnnotationsLayout, 1)
@@ -622,6 +630,12 @@ internal class ViewAnnotationManagerImpl(
622630
}
623631

624632
private fun prepareViewAnnotation(inflatedView: View, options: ViewAnnotationOptions) {
633+
// viewAnnotationsLayout is pinned to LTR (see init block) so Gravity.START-based positioning
634+
// isn't affected by the host app's locale. That would also flatten this view's own content
635+
// (Gravity.START/END, marginStart/End, etc.) to LTR by inheritance, so resolve it against the
636+
// real system locale instead - positioning is governed entirely by the parent's layoutDirection,
637+
// never the child's, so this has no effect on where the view ends up on the map.
638+
inflatedView.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
625639
measureView(inflatedView)
626640
val inflatedViewLayoutParams = inflatedView.layoutParams as ViewGroup.LayoutParams
627641
// If values in layout params are negative - view is wrap_content (assuming match_parent view annotations

maps-sdk/src/test/java/com/mapbox/maps/ViewAnnotationManagerAddTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class ViewAnnotationManagerAddTest(
6767
viewAnnotationsLayout = mockk()
6868
every { viewAnnotationsLayout.layoutParams = any() } just Runs
6969
every { viewAnnotationsLayout.setClipChildren(any()) } just Runs
70+
every { viewAnnotationsLayout.layoutDirection = any() } just Runs
7071
every { viewAnnotationsLayout.addView(any()) } just Runs
7172
every { viewAnnotationsLayout.removeView(any()) } just Runs
7273
every { viewAnnotationsLayout.context } returns mockk()

maps-sdk/src/test/java/com/mapbox/maps/ViewAnnotationManagerTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class ViewAnnotationManagerTest {
5454
viewAnnotationsLayout = mockk()
5555
every { viewAnnotationsLayout.layoutParams = any() } just Runs
5656
every { viewAnnotationsLayout.setClipChildren(any()) } just Runs
57+
every { viewAnnotationsLayout.layoutDirection = any() } just Runs
5758
every { viewAnnotationsLayout.removeView(any()) } just Runs
5859
every { mapboxMap.addViewAnnotation(any(), any()) } returns ExpectedFactory.createNone()
5960
val displayMetrics = DisplayMetrics().apply { density = 1f }
@@ -65,6 +66,7 @@ class ViewAnnotationManagerTest {
6566
}
6667

6768
private fun mockView(): View = mockk<View>().also {
69+
every { it.layoutDirection = any() } just Runs
6870
every { it.layoutParams } returns frameLayoutParams
6971
every { it.measuredWidth } returns 10
7072
every { it.measuredHeight } returns 10
@@ -85,6 +87,18 @@ class ViewAnnotationManagerTest {
8587
unmockkStatic(MeasureSpec::class)
8688
}
8789

90+
@Test
91+
fun viewAnnotationsLayoutIsPinnedToLtr() {
92+
// Annotation views get an unset (default) Gravity, resolved by FrameLayout to
93+
// Gravity.START|TOP. If this container inherited RTL from a host MapView with
94+
// layoutDirection = RTL, that default would mirror to the right edge, while
95+
// positionAnnotationViews() still applies translationX/Y computed assuming a
96+
// left-anchored (x=0) base - landing views off-screen or in the wrong corner.
97+
// The container must be pinned to LTR so it always matches that left-anchored
98+
// coordinate system, regardless of the host app's locale/layoutDirection.
99+
verifyOnce { viewAnnotationsLayout.layoutDirection = View.LAYOUT_DIRECTION_LTR }
100+
}
101+
88102
@Test
89103
fun addViewAnnotationWithDuplicateView() {
90104
viewAnnotationManager.addViewAnnotation(
@@ -558,6 +572,7 @@ class ViewAnnotationManagerTest {
558572
childViews: List<View> = emptyList(),
559573
selfTag: Any? = null,
560574
): FrameLayout = mockk<FrameLayout>().also { root ->
575+
every { root.layoutDirection = any() } just Runs
561576
every { root.layoutParams } returns FrameLayout.LayoutParams(100, 100)
562577
every { root.measuredWidth } returns 100
563578
every { root.measuredHeight } returns 100

0 commit comments

Comments
 (0)