Skip to content

Commit cf0da50

Browse files
kediarovgithub-actions[bot]
authored andcommitted
[compose] Indoor selector (#14354)
MAPSAND-2778 https://github.com/user-attachments/assets/16da5b11-ebcf-4e08-9e06-5cce02e77c26 ### Summary - Add `IndoorSelector` composable to `MapboxMap` for Compose-native indoor floor navigation - New `IndoorState` subscribes to `IndoorManager`, drives floor list and selection - Add `IndoorExampleActivity` to compose-app demo (ornaments category) - Add unit tests for `IndoorState` ### Key design decisions **`selectedFloorId` is `String?` (not `String`)** - `null` = no floor selected (building level) → calls `IndoorManager.selectFloor(null)` to deselect - Core `com.mapbox.maps.IndoorState.selectedFloorId` is `String` where empty = no selection; the Compose layer maps empty → `null` in `onIndoorUpdated` so the public API uses nullable semantics consistently with `selectFloor(null)` **Compose vs View parity** - View plugin requires explicit opt-in (`Plugin.Mapbox(Plugin.MAPBOX_INDOOR_SELECTOR_PLUGIN_ID)`); Compose `indoorSelector` slot defaults to `{}` (no-op), also opt-in - View plugin exposes `addOnFloorSelectedListener`/`removeOnFloorSelectedListener` (multiple listeners); Compose uses a single `OnFloorSelectedListener` callback + hoisted `IndoorState` — idiomatic Compose - View plugin cannot programmatically select a floor (listener fires only on UI click); Compose `IndoorState.selectedFloorId` setter enables programmatic floor selection GitOrigin-RevId: 9053370f8a87a5922ac637f9bacc1079ebcbfd5c
1 parent f1f5d85 commit cf0da50

14 files changed

Lines changed: 980 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ Mapbox welcomes participation and contributions from everyone.
55
> **16 KB Page Size Support:** Starting with version 11.7.0 and 10.19.0, **NDK 27 is supported** with dedicated artifacts that include [support for 16 KB page sizes](https://developer.android.com/guide/practices/page-sizes). If your app does not require 16 KB page size support, you can keep using our default artifacts without `-ndk27` suffix. For more information about our NDK support, see https://docs.mapbox.com/android/maps/guides/#ndk-support
66

77
# main
8+
## Features ✨ and improvements 🏁
9+
* [compose] Introduce experimental `IndoorSelector` composable function available inside `MapboxMap`, displaying a scrollable floor-selection widget that appears automatically when an indoor building is in view. Exposes `IndoorSelectorState` for programmatic access to the current floor list and selected floor, and an `onFloorClicked` callback for reacting to user selections.
10+
* [compose] Add `IndoorSelectorControl` headless composable inside `MapIndoorSelectorScope`: attaches the indoor plugin to an `IndoorSelectorState` without rendering any UI, enabling custom floor-selector implementations.
811

912
# 11.25.0-rc.2 June 04, 2026
1013
## Bug fixes 🐞
@@ -13,7 +16,6 @@ Mapbox welcomes participation and contributions from everyone.
1316
## Dependencies
1417
* Update gl-native to [v11.25.0-rc.2](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.25.0-rc.2), common to [v24.25.0-rc.2](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.25.0-rc.2).
1518

16-
1719
# 11.25.0-rc.1 June 02, 2026
1820
## Breaking changes ⚠️
1921
* `MapView.setMaximumFps` and `MapSurface.setMaximumFps` are now annotated `@MainThread`. Callers must invoke them from the main thread; off-main callers will see a lint warning.

app/src/main/java/com/mapbox/maps/testapp/examples/IndoorExampleActivity.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.mapbox.maps.testapp.examples
22

33
import android.os.Bundle
4+
import android.util.Log
45
import androidx.appcompat.app.AppCompatActivity
6+
import com.mapbox.bindgen.Value
57
import com.mapbox.geojson.Point
68
import com.mapbox.maps.MapInitOptions
79
import com.mapbox.maps.MapView
@@ -60,11 +62,21 @@ class IndoorExampleActivity : AppCompatActivity() {
6062
mapView.mapboxMap.loadStyle(style)
6163
}
6264
}
65+
mapView.mapboxMap.getStyle { style ->
66+
style.setStyleImportConfigProperty(
67+
"basemap",
68+
"showIndoor",
69+
Value.valueOf(true),
70+
)
71+
}
6372

6473
mapView.scalebar.enabled = true
6574

6675
mapView.indoorSelector.enabled = true
6776
mapView.indoorSelector.marginTop = 160f
77+
mapView.indoorSelector.addOnFloorSelectedListener { floor ->
78+
Log.d("IndoorExample", "Floor selected: $floor")
79+
}
6880

6981
locationPermissionHelper = LocationPermissionHelper(WeakReference(this))
7082
locationPermissionHelper.checkPermissions {

compose-app/src/main/AndroidManifest.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,28 @@
239239
android:name="@string/category"
240240
android:value="@string/category_basic" />
241241
</activity>
242+
<activity
243+
android:name=".examples.ornaments.IndoorExampleActivity"
244+
android:configChanges="orientation|screenSize|screenLayout"
245+
android:description="@string/description_indoor_example"
246+
android:exported="true"
247+
android:label="@string/activity_indoor_example"
248+
android:parentActivityName=".ExampleOverviewActivity">
249+
<meta-data
250+
android:name="@string/category"
251+
android:value="@string/category_ornaments" />
252+
</activity>
253+
<activity
254+
android:name=".examples.ornaments.CustomIndoorSelectorExampleActivity"
255+
android:configChanges="orientation|screenSize|screenLayout"
256+
android:description="@string/description_custom_indoor_selector_example"
257+
android:exported="true"
258+
android:label="@string/activity_custom_indoor_selector_example"
259+
android:parentActivityName=".ExampleOverviewActivity">
260+
<meta-data
261+
android:name="@string/category"
262+
android:value="@string/category_ornaments" />
263+
</activity>
242264
<activity
243265
android:name=".examples.style.StyleStatesActivity"
244266
android:description="@string/description_style_states"
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package com.mapbox.maps.compose.testapp.examples.ornaments
2+
3+
import android.os.Bundle
4+
import android.util.Log
5+
import androidx.activity.ComponentActivity
6+
import androidx.activity.compose.setContent
7+
import androidx.compose.foundation.background
8+
import androidx.compose.foundation.clickable
9+
import androidx.compose.foundation.layout.Box
10+
import androidx.compose.foundation.layout.Column
11+
import androidx.compose.foundation.layout.fillMaxSize
12+
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.layout.size
14+
import androidx.compose.foundation.layout.width
15+
import androidx.compose.foundation.shape.RoundedCornerShape
16+
import androidx.compose.material.MaterialTheme
17+
import androidx.compose.material.Surface
18+
import androidx.compose.material.Text
19+
import androidx.compose.runtime.remember
20+
import androidx.compose.ui.Alignment
21+
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.text.style.TextAlign
23+
import androidx.compose.ui.unit.dp
24+
import androidx.compose.ui.unit.sp
25+
import com.mapbox.geojson.Point
26+
import com.mapbox.maps.MapboxExperimental
27+
import com.mapbox.maps.compose.testapp.ExampleScaffold
28+
import com.mapbox.maps.compose.testapp.ui.theme.MapboxMapComposeTheme
29+
import com.mapbox.maps.extension.compose.MapboxMap
30+
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
31+
import com.mapbox.maps.extension.compose.ornaments.indoorselector.rememberIndoorSelectorState
32+
import com.mapbox.maps.extension.compose.style.BooleanValue
33+
import com.mapbox.maps.extension.compose.style.standard.MapboxStandardStyle
34+
import com.mapbox.maps.extension.compose.style.standard.StandardStyleState
35+
36+
/**
37+
* Demonstrates a custom indoor floor-selector UI built on top of `IndoorSelectorControl`.
38+
*
39+
* `IndoorSelectorControl` attaches the plugin headlessly inside `indoorSelector = { }`;
40+
* the floor list is read directly from [IndoorSelectorState][com.mapbox.maps.extension.compose.ornaments.indoorselector.IndoorSelectorState].
41+
* Uses a plain [Column] (no scroll) so the widget is always wrap-content tall.
42+
*/
43+
@OptIn(MapboxExperimental::class, com.mapbox.annotation.MapboxExperimental::class)
44+
public class CustomIndoorSelectorExampleActivity : ComponentActivity() {
45+
override fun onCreate(savedInstanceState: Bundle?) {
46+
super.onCreate(savedInstanceState)
47+
setContent {
48+
MapboxMapComposeTheme {
49+
ExampleScaffold {
50+
val indoorState = rememberIndoorSelectorState()
51+
MapboxMap(
52+
modifier = Modifier.fillMaxSize(),
53+
mapViewportState = rememberMapViewportState {
54+
setCameraOptions {
55+
center(Point.fromLngLat(LONGITUDE, LATITUDE))
56+
zoom(ZOOM)
57+
bearing(BEARING)
58+
pitch(PITCH)
59+
}
60+
},
61+
indoorSelector = {
62+
// Attach the plugin to indoorState without rendering the default UI.
63+
IndoorSelectorControl(state = indoorState)
64+
65+
// Custom floor-selector panel — wrap in Box to get BoxScope alignment.
66+
// Visible only when an indoor building is in view.
67+
val floors = indoorState.floors
68+
if (floors.isEmpty()) return@MapboxMap
69+
Box(modifier = Modifier.fillMaxSize()) {
70+
Surface(
71+
modifier = Modifier
72+
.align(Alignment.TopEnd)
73+
.padding(top = 80.dp, end = 16.dp),
74+
shape = RoundedCornerShape(8.dp),
75+
elevation = 4.dp,
76+
) {
77+
val selectedBg = MaterialTheme.colors.primary
78+
val selectedText = MaterialTheme.colors.onPrimary
79+
val bg = MaterialTheme.colors.surface
80+
val text = MaterialTheme.colors.onSurface
81+
Column(
82+
modifier = Modifier.width(ITEM_SIZE),
83+
horizontalAlignment = Alignment.CenterHorizontally,
84+
) {
85+
// Building item — clears the active floor selection.
86+
val isBuildingSelected = indoorState.selectedFloorId == null
87+
Box(
88+
modifier = Modifier
89+
.size(ITEM_SIZE)
90+
.background(if (isBuildingSelected) selectedBg else bg)
91+
.clickable { indoorState.selectedFloorId = null },
92+
contentAlignment = Alignment.Center,
93+
) {
94+
Text(
95+
text = "🏢",
96+
color = if (isBuildingSelected) selectedText else text,
97+
fontSize = ITEM_TEXT_SP,
98+
textAlign = TextAlign.Center,
99+
)
100+
}
101+
102+
// One item per floor — no scroll, wrap-content height.
103+
floors.forEach { floor ->
104+
val isSelected = floor.id == indoorState.selectedFloorId
105+
Box(
106+
modifier = Modifier
107+
.size(ITEM_SIZE)
108+
.background(if (isSelected) selectedBg else bg)
109+
.clickable {
110+
indoorState.selectedFloorId = floor.id
111+
Log.d(TAG, "Floor tapped: ${floor.id} (${floor.name})")
112+
},
113+
contentAlignment = Alignment.Center,
114+
) {
115+
Text(
116+
text = floor.name.take(FLOOR_LABEL_MAX_CHARS),
117+
color = if (isSelected) selectedText else text,
118+
fontSize = ITEM_TEXT_SP,
119+
textAlign = TextAlign.Center,
120+
)
121+
}
122+
}
123+
}
124+
}
125+
}
126+
},
127+
style = {
128+
MapboxStandardStyle(
129+
standardStyleState = remember {
130+
StandardStyleState().apply {
131+
configurationsState.showIndoor = BooleanValue(true)
132+
}
133+
}
134+
)
135+
},
136+
)
137+
}
138+
}
139+
}
140+
}
141+
142+
private companion object {
143+
private const val TAG = "CustomIndoorExample"
144+
private const val FLOOR_LABEL_MAX_CHARS = 3
145+
private val ITEM_SIZE = 44.dp
146+
private val ITEM_TEXT_SP = 16.sp
147+
const val LATITUDE = 35.55025
148+
const val LONGITUDE = 139.794131
149+
const val ZOOM = 16.0
150+
const val BEARING = 12.0
151+
const val PITCH = 60.0
152+
}
153+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.mapbox.maps.compose.testapp.examples.ornaments
2+
3+
import android.os.Bundle
4+
import android.util.Log
5+
import androidx.activity.ComponentActivity
6+
import androidx.activity.compose.setContent
7+
import androidx.compose.foundation.layout.Box
8+
import androidx.compose.foundation.layout.PaddingValues
9+
import androidx.compose.foundation.layout.Row
10+
import androidx.compose.foundation.layout.fillMaxSize
11+
import androidx.compose.foundation.layout.padding
12+
import androidx.compose.material.Button
13+
import androidx.compose.material.Text
14+
import androidx.compose.runtime.getValue
15+
import androidx.compose.runtime.mutableStateOf
16+
import androidx.compose.runtime.remember
17+
import androidx.compose.runtime.setValue
18+
import androidx.compose.ui.Alignment
19+
import androidx.compose.ui.Modifier
20+
import androidx.compose.ui.unit.dp
21+
import com.mapbox.geojson.Point
22+
import com.mapbox.maps.MapboxExperimental
23+
import com.mapbox.maps.compose.testapp.ExampleScaffold
24+
import com.mapbox.maps.compose.testapp.ui.theme.MapboxMapComposeTheme
25+
import com.mapbox.maps.extension.compose.MapboxMap
26+
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
27+
import com.mapbox.maps.extension.compose.ornaments.indoorselector.rememberIndoorSelectorState
28+
import com.mapbox.maps.extension.compose.style.BooleanValue
29+
import com.mapbox.maps.extension.compose.style.standard.MapboxStandardStyle
30+
import com.mapbox.maps.extension.compose.style.standard.StandardStyleState
31+
32+
/**
33+
* Example of the [IndoorSelector] ornament backed by hoisted [rememberIndoorSelectorState].
34+
* Demonstrates toggling the indoor layer on/off and selecting a floor programmatically.
35+
*/
36+
@OptIn(MapboxExperimental::class, com.mapbox.annotation.MapboxExperimental::class)
37+
public class IndoorExampleActivity : ComponentActivity() {
38+
override fun onCreate(savedInstanceState: Bundle?) {
39+
super.onCreate(savedInstanceState)
40+
setContent {
41+
MapboxMapComposeTheme {
42+
ExampleScaffold {
43+
val indoorState = rememberIndoorSelectorState()
44+
var enabled by remember { mutableStateOf(true) }
45+
Box(modifier = Modifier.fillMaxSize()) {
46+
MapboxMap(
47+
modifier = Modifier.fillMaxSize(),
48+
mapViewportState = rememberMapViewportState {
49+
setCameraOptions {
50+
center(Point.fromLngLat(LONGITUDE, LATITUDE))
51+
zoom(ZOOM)
52+
bearing(BEARING)
53+
pitch(PITCH)
54+
}
55+
},
56+
indoorSelector = {
57+
if (!enabled) return@MapboxMap
58+
IndoorSelector(
59+
state = indoorState,
60+
alignment = Alignment.TopEnd,
61+
contentPadding = PaddingValues(top = 80.dp, end = 20.dp),
62+
onFloorClicked = { floor ->
63+
Log.d("IndoorExample", "Floor clicked: ${floor.id}")
64+
}
65+
)
66+
},
67+
style = {
68+
MapboxStandardStyle(
69+
standardStyleState = remember(enabled) {
70+
StandardStyleState().apply {
71+
configurationsState.showIndoor = BooleanValue(enabled)
72+
}
73+
}
74+
)
75+
},
76+
)
77+
Row(
78+
modifier = Modifier
79+
.align(Alignment.BottomCenter)
80+
.padding(bottom = 16.dp),
81+
) {
82+
Button(
83+
onClick = {
84+
indoorState.floors.firstOrNull()?.let { indoorState.selectedFloorId = it.id }
85+
},
86+
enabled = indoorState.floors.isNotEmpty(),
87+
) {
88+
Text("Top Floor")
89+
}
90+
Button(
91+
onClick = { enabled = !enabled },
92+
modifier = Modifier.padding(start = 8.dp),
93+
) {
94+
Text(if (enabled) "Disable" else "Enable")
95+
}
96+
}
97+
}
98+
}
99+
}
100+
}
101+
}
102+
103+
private companion object {
104+
const val LATITUDE = 35.55025
105+
const val LONGITUDE = 139.794131
106+
const val ZOOM = 16.0
107+
const val BEARING = 12.0
108+
const val PITCH = 60.0
109+
}
110+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
<string name="description_style_composition">Runtime styling with style composition</string>
1919
<string name="description_navigation_simulation">Simulate navigation experience with compose</string>
2020
<string name="description_multi_map">Showcase displaying two maps in the same Activity</string>
21+
<string name="description_indoor_example">Compose-native indoor floor selector backed by a hoisted IndoorState.</string>
22+
<string name="description_custom_indoor_selector_example">Custom floor-selector UI built with IndoorSelectorControl and a plain Column (no scroll).</string>
2123
<string name="description_style_states">Showcase using style states</string>
2224
<string name="description_style_positions">Showcase usage of slotsContent and layerPositionedContent to arrange contents within the base style using GenericStyle API.</string>
2325
<string name="description_model_layer">Showcase the usage of a 3D model layer.</string>

compose-app/src/main/res/values/example_titles.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
<string name="activity_style_composition">Style composition</string>
1919
<string name="activity_navigation_simulation">Simulate navigation</string>
2020
<string name="activity_multi_map">Display multiple maps</string>
21+
<string name="activity_indoor_example">Indoor Example</string>
22+
<string name="activity_custom_indoor_selector_example">Custom indoor selector</string>
2123
<string name="activity_style_states">Style States</string>
2224
<string name="activity_style_positions">Position content using GenericStyle</string>
2325
<string name="activity_model_layer">Display 3D model in a model layer</string>

0 commit comments

Comments
 (0)