Skip to content

Commit cc3d323

Browse files
persidskiygithub-actions[bot]
authored andcommitted
View Annotations collision (#12646)
This PR implements collision detection between view annotations and symbol layer. Previously, to achieve the same effect for View Annotation would need to create a separate transparent symbol layer for each view annotations with the same dimensions. GitOrigin-RevId: cbd2914c81d32bc9f0799468b32a3b91c0f9349e
1 parent 726b665 commit cc3d323

17 files changed

Lines changed: 812 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ Mapbox welcomes participation and contributions from everyone.
1515
* Update gl-native to [v11.25.0](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.25.0), common to [v24.25.0](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.25.0).
1616

1717

18+
## Features ✨ and improvements 🏁
19+
* Introduce new experimental `ViewAnnotationOptions.enableSymbolLayerCollision` option which allows view annotations to hide underlying map symbols to avoid visual clutter.
20+
By default, the full bounding box of the view annotation is used for collision detection. If your annotation has a non-rectangular shape, it is highly recommended to mark the specific subviews that should participate via the new experimental `View.mbxCollisionBox` flag.
21+
* Support drawing view annotation collision boxes when `MapView.debugOptions` is set to `COLLISION`.
22+
1823
# 11.25.0-rc.2 June 04, 2026
1924
## Bug fixes 🐞
2025
* Internal fixes and performance improvements.
@@ -5463,4 +5468,3 @@ To get started with v10, please refer to our [migration guide](https://docs.mapb
54635468
* An invalid LatLng conversion can occur and produce a native crash
54645469
* Native crash when resuming the map in specific situations
54655470
* Native crash when performing a camera transition using Map#jumpTo
5466-

app/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,18 @@
12531253
<meta-data
12541254
android:name="android.support.PARENT_ACTIVITY"
12551255
android:value=".ExampleOverviewActivity" />
1256+
</activity>
1257+
<activity
1258+
android:name=".examples.markersandcallouts.viewannotation.ViewAnnotationCollisionActivity"
1259+
android:description="@string/description_view_annotation_collision"
1260+
android:exported="true"
1261+
android:label="@string/activity_view_annotation_collision">
1262+
<meta-data
1263+
android:name="@string/category"
1264+
android:value="@string/category_markers_and_callouts" />
1265+
<meta-data
1266+
android:name="android.support.PARENT_ACTIVITY"
1267+
android:value=".ExampleOverviewActivity" />
12561268
</activity> <!-- -->
12571269
<activity
12581270
android:name=".examples.viewport.ViewportShowcaseActivity"

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
336336
btnMode.text = getString(R.string.dynamic_mode, if (isOverview) "follow" else "overview")
337337
}
338338

339+
@OptIn(com.mapbox.maps.MapboxExperimental::class)
339340
private fun addViewAnnotations() {
340341
etaView = viewAnnotationManager.addViewAnnotation(
341342
resId = R.layout.item_dva_eta,
@@ -356,6 +357,7 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
356357
},
357358
)
358359
minZoom(8f)
360+
enableSymbolLayerCollision(true)
359361
}
360362
)
361363
alternativeEtaView = viewAnnotationManager.addViewAnnotation(
@@ -377,6 +379,7 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
377379
},
378380
)
379381
minZoom(8f)
382+
enableSymbolLayerCollision(true)
380383
}
381384
)
382385
alternativeEtaView.setOnClickListener {
@@ -392,6 +395,7 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
392395
featureId(PARKING_FEATURE_ID_1)
393396
}
394397
minZoom(10f)
398+
enableSymbolLayerCollision(true)
395399
}
396400
)
397401

@@ -404,6 +408,7 @@ class DynamicViewAnnotationActivity : AppCompatActivity() {
404408
featureId(PARKING_FEATURE_ID_2)
405409
}
406410
minZoom(12f)
411+
enableSymbolLayerCollision(true)
407412
}
408413
)
409414

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package com.mapbox.maps.testapp.examples.markersandcallouts.viewannotation
2+
3+
import android.animation.ValueAnimator
4+
import android.content.Context
5+
import android.graphics.Canvas
6+
import android.graphics.Color
7+
import android.graphics.LinearGradient
8+
import android.graphics.Paint
9+
import android.graphics.Path
10+
import android.graphics.RectF
11+
import android.graphics.Shader
12+
import android.graphics.Typeface
13+
import android.view.Gravity
14+
import android.view.View
15+
import android.view.ViewGroup
16+
import android.view.animation.OvershootInterpolator
17+
import android.widget.LinearLayout
18+
import androidx.core.graphics.withTranslation
19+
import com.mapbox.maps.MapboxExperimental
20+
import com.mapbox.maps.viewannotation.mbxCollisionBox
21+
22+
/**
23+
* PinView renders a pin shape (gradient-filled teardrop with hole) and a label below it.
24+
* Both the pin and the label are marked as [mbxCollisionBox] so they can collide
25+
* independently with map symbols.
26+
*/
27+
@OptIn(MapboxExperimental::class)
28+
class PinView(context: Context, text: String) : LinearLayout(context) {
29+
30+
/** Y offset (px, from view top) of the pin's tip — i.e. the bottom of the pin shape. */
31+
val pinTipY: Int
32+
33+
init {
34+
val d = resources.displayMetrics.density
35+
val pinSize = (35 * d).toInt()
36+
pinTipY = pinSize * 3 / 2
37+
layoutParams = ViewGroup.LayoutParams(
38+
ViewGroup.LayoutParams.WRAP_CONTENT,
39+
ViewGroup.LayoutParams.WRAP_CONTENT
40+
)
41+
orientation = VERTICAL
42+
gravity = Gravity.CENTER_HORIZONTAL
43+
clipChildren = false
44+
clipToPadding = false
45+
addView(
46+
PinShapeView(context),
47+
LayoutParams(pinSize, pinTipY).apply { bottomMargin = (3 * d).toInt() }
48+
)
49+
addView(
50+
StrokedTextView(context).apply {
51+
this.text = text
52+
setTextColor(Color.RED)
53+
textSize = 13.5f
54+
setTypeface(typeface, Typeface.BOLD)
55+
gravity = Gravity.CENTER
56+
maxWidth = (130 * d).toInt()
57+
mbxCollisionBox = true
58+
},
59+
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
60+
)
61+
}
62+
63+
private class StrokedTextView(context: Context) : androidx.appcompat.widget.AppCompatTextView(context) {
64+
private val strokeColor = Color.WHITE
65+
66+
override fun onDraw(canvas: Canvas) {
67+
val textLayout = layout ?: return super.onDraw(canvas)
68+
val p = paint
69+
val originalStyle = p.style
70+
val originalStrokeWidth = p.strokeWidth
71+
canvas.withTranslation(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat()) {
72+
// Stroke pass: white outline. We bypass super.onDraw + setTextColor to avoid the
73+
// invalidate() that setTextColor triggers, which would cause a 60fps redraw loop.
74+
p.style = Paint.Style.STROKE
75+
p.strokeWidth = resources.displayMetrics.density
76+
p.color = strokeColor
77+
textLayout.draw(this)
78+
// Fill pass: original text color on top of the outline.
79+
p.style = originalStyle
80+
p.strokeWidth = originalStrokeWidth
81+
p.color = currentTextColor
82+
textLayout.draw(this)
83+
}
84+
}
85+
}
86+
87+
private class PinShapeView(context: Context) : View(context) {
88+
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
89+
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
90+
style = Paint.Style.STROKE
91+
strokeWidth = resources.displayMetrics.density
92+
color = Color.argb((0.4f * 255).toInt(), 255, 255, 255)
93+
}
94+
private val path = Path().apply { fillType = Path.FillType.EVEN_ODD }
95+
96+
init {
97+
mbxCollisionBox = true
98+
fillPaint.setShadowLayer(10f, 0f, 0f, Color.argb(128, 0, 0, 0))
99+
scaleX = 0f
100+
scaleY = 0f
101+
}
102+
103+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
104+
val width = w.toFloat()
105+
val height = h.toFloat()
106+
val r = width / 2
107+
val angle = Math.PI / 4
108+
val sx = (r + r * Math.cos(angle)).toFloat()
109+
val sy = (r + r * Math.sin(angle)).toFloat()
110+
path.reset()
111+
path.moveTo(sx, sy)
112+
path.arcTo(RectF(0f, 0f, width, width), 45f, -270f, true)
113+
path.lineTo(r, height)
114+
path.lineTo(sx, sy)
115+
path.addCircle(r, r, r / 3, Path.Direction.CCW)
116+
fillPaint.shader = LinearGradient(
117+
0f, 0f, 0f, height, Color.RED, Color.BLUE, Shader.TileMode.CLAMP
118+
)
119+
pivotX = r
120+
pivotY = height
121+
ValueAnimator.ofFloat(0f, 1f).apply {
122+
duration = 400
123+
interpolator = OvershootInterpolator()
124+
addUpdateListener {
125+
val v = it.animatedValue as Float
126+
scaleX = v
127+
scaleY = v
128+
}
129+
start()
130+
}
131+
}
132+
133+
override fun onDraw(canvas: Canvas) {
134+
canvas.drawPath(path, fillPaint)
135+
canvas.drawPath(path, strokePaint)
136+
}
137+
}
138+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.mapbox.maps.testapp.examples.markersandcallouts.viewannotation
2+
3+
import android.os.Bundle
4+
import android.view.View
5+
import androidx.appcompat.app.AppCompatActivity
6+
import com.mapbox.geojson.Point
7+
import com.mapbox.maps.ClickInteraction
8+
import com.mapbox.maps.MapboxDelicateApi
9+
import com.mapbox.maps.MapboxExperimental
10+
import com.mapbox.maps.ViewAnnotationAnchor
11+
import com.mapbox.maps.debugoptions.MapViewDebugOptions
12+
import com.mapbox.maps.dsl.cameraOptions
13+
import com.mapbox.maps.interactions.standard.generated.StandardPoiState
14+
import com.mapbox.maps.interactions.standard.generated.StandardPoiStateKey
15+
import com.mapbox.maps.interactions.standard.generated.standardPoi
16+
import com.mapbox.maps.testapp.databinding.ActivityViewAnnotationCollisionBinding
17+
import com.mapbox.maps.viewannotation.ViewAnnotationManager
18+
import com.mapbox.maps.viewannotation.annotationAnchors
19+
import com.mapbox.maps.viewannotation.geometry
20+
import com.mapbox.maps.viewannotation.viewAnnotationOptions
21+
22+
/**
23+
* Example showing a single view annotation rendered as a [PinView] (pin shape + label),
24+
* mirroring the SwiftUI PinView used in maps-ios examples. The annotation participates
25+
* in symbol-layer collision so the pin and its label can be hidden by overlapping map symbols.
26+
*/
27+
@OptIn(MapboxExperimental::class)
28+
class ViewAnnotationCollisionActivity : AppCompatActivity() {
29+
30+
private var symbolCollisionEnabled = true
31+
private var allowOverlapEnabled = true
32+
private val allPins = mutableListOf<View>()
33+
private lateinit var vaManager: ViewAnnotationManager
34+
35+
@OptIn(MapboxDelicateApi::class)
36+
override fun onCreate(savedInstanceState: Bundle?) {
37+
super.onCreate(savedInstanceState)
38+
val binding = ActivityViewAnnotationCollisionBinding.inflate(layoutInflater)
39+
setContentView(binding.root)
40+
vaManager = binding.mapView.viewAnnotationManager
41+
42+
binding.debugToggle.setOnCheckedChangeListener { _, isChecked ->
43+
binding.mapView.debugOptions = if (isChecked) setOf(MapViewDebugOptions.COLLISION) else emptySet()
44+
}
45+
46+
binding.symbolCollisionToggle.setOnCheckedChangeListener { _, isChecked ->
47+
symbolCollisionEnabled = isChecked
48+
updateAllPinOptions()
49+
}
50+
51+
binding.vaAllowOverlap.setOnCheckedChangeListener { _, isChecked ->
52+
allowOverlapEnabled = isChecked
53+
updateAllPinOptions()
54+
}
55+
56+
val point = Point.fromLngLat(-122.4194, 37.7749)
57+
val pin = PinView(this, text = "San Francisco")
58+
59+
binding.mapView.mapboxMap.apply {
60+
setCamera(cameraOptions { center(point); zoom(14.0) })
61+
vaManager.addViewAnnotation(
62+
view = pin,
63+
options = pinOptions(point, pin.pinTipY)
64+
)
65+
allPins += pin
66+
67+
addInteraction(
68+
ClickInteraction.standardPoi { poi, _ ->
69+
setFeatureState(poi, StandardPoiState { hide(true) }) {
70+
val poiPin = PinView(binding.mapView.context, text = poi.name ?: "")
71+
poiPin.setOnClickListener {
72+
removeFeatureState(poi, StandardPoiStateKey.HIDE)
73+
vaManager.removeViewAnnotation(poiPin)
74+
allPins -= poiPin
75+
}
76+
vaManager.addViewAnnotation(
77+
view = poiPin,
78+
options = pinOptions(poi.geometry, poiPin.pinTipY)
79+
)
80+
allPins += poiPin
81+
}
82+
return@standardPoi true
83+
}
84+
)
85+
}
86+
}
87+
88+
private fun updateAllPinOptions() {
89+
allPins.forEach { view ->
90+
vaManager.updateViewAnnotation(
91+
view,
92+
viewAnnotationOptions {
93+
enableSymbolLayerCollision(symbolCollisionEnabled)
94+
allowOverlap(allowOverlapEnabled)
95+
}
96+
)
97+
}
98+
}
99+
100+
private fun pinOptions(point: Point, tipY: Int) = viewAnnotationOptions {
101+
geometry(point)
102+
allowOverlap(allowOverlapEnabled)
103+
allowOverlapWithPuck(true)
104+
ignoreCameraPadding(true)
105+
enableSymbolLayerCollision(symbolCollisionEnabled)
106+
annotationAnchors(
107+
{
108+
anchor(ViewAnnotationAnchor.TOP)
109+
offsetY(tipY.toDouble())
110+
}
111+
)
112+
}
113+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:app="http://schemas.android.com/apk/res-auto"
4+
android:layout_width="match_parent"
5+
android:layout_height="match_parent">
6+
7+
<com.mapbox.maps.MapView
8+
android:id="@+id/mapView"
9+
android:layout_width="match_parent"
10+
android:layout_height="match_parent" />
11+
12+
<LinearLayout
13+
android:id="@+id/bottomPanel"
14+
android:layout_width="0dp"
15+
android:layout_height="wrap_content"
16+
android:layout_marginHorizontal="16dp"
17+
android:layout_marginBottom="32dp"
18+
android:background="@drawable/bg_rounded_corner"
19+
android:orientation="vertical"
20+
android:paddingStart="12dp"
21+
android:paddingTop="8dp"
22+
android:paddingEnd="12dp"
23+
android:paddingBottom="8dp"
24+
app:layout_constraintBottom_toBottomOf="parent"
25+
app:layout_constraintEnd_toEndOf="parent"
26+
app:layout_constraintStart_toStartOf="parent">
27+
28+
<TextView
29+
android:layout_width="wrap_content"
30+
android:layout_height="wrap_content"
31+
android:text="@string/view_annotation_collision_hint" />
32+
33+
<androidx.appcompat.widget.SwitchCompat
34+
android:id="@+id/debugToggle"
35+
android:layout_width="wrap_content"
36+
android:layout_height="wrap_content"
37+
android:layout_marginTop="4dp"
38+
android:text="@string/collision_debug" />
39+
40+
<androidx.appcompat.widget.SwitchCompat
41+
android:id="@+id/symbolCollisionToggle"
42+
android:layout_width="wrap_content"
43+
android:layout_height="wrap_content"
44+
android:checked="true"
45+
android:text="@string/va_symbol_collision" />
46+
47+
<androidx.appcompat.widget.SwitchCompat
48+
android:id="@+id/vaAllowOverlap"
49+
android:layout_width="wrap_content"
50+
android:layout_height="wrap_content"
51+
android:checked="true"
52+
android:text="@string/va_allow_overlap" />
53+
54+
</LinearLayout>
55+
56+
</androidx.constraintlayout.widget.ConstraintLayout>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
<string name="description_view_annotation_animation">Animate a view annotation on screen</string>
100100
<string name="description_view_annotation_point_annotation">Add view annotation to a point annotation</string>
101101
<string name="description_dynamic_view_annotations">Add dynamic view annotations to line layers and fixed coordinates</string>
102+
<string name="description_view_annotation_collision">Pin view annotation rendered hiding the basemap symbols below it</string>
102103
<string name="description_info_window">Legacy InfoWindow implementation using view annotations</string>
103104
<string name="description_viewport">Viewport camera showcase</string>
104105
<string name="description_advanced_viewport">Advanced viewport with gestures showcase</string>

0 commit comments

Comments
 (0)