Mini-Map¶
Embed a floor plan preview in your content screens and expand it into a full interactive map on tap.
Overview¶
A mini-map is a floor plan preview embedded directly in your app — right where attendees are already looking. Tap an exhibitor and see exactly where their booth is. Tap again to expand into a full interactive map with wayfinding directions.
The example follows a three-screen flow:
- Exhibitors List — loads exhibitors from the SDK and renders them as a scrollable list.
- Exhibitor Detail — shows an inline floor plan preview with the selected booth highlighted, plus Expand and Directions buttons.
- Fullscreen Plan — the mini-map expands into a full interactive plan with Collapse and Directions controls.
Quick Start¶
The full source is in the Android examples repository:
git clone https://github.com/expofp/expofp-sdk-android-examples.git
cd expofp-sdk-android-examples
Open in Android Studio, select the mini-map run configuration, and run on a device or emulator.
Prerequisites: Android Studio Ladybug or later, Min SDK 26, Target/Compile SDK 36.
Architecture¶
The app follows MVVM + Single Activity with Jetpack Compose, Hilt for dependency injection, and Navigation Compose for routing.
- Single presenter with preloading — unlike iOS (which creates two presenters), Android uses a single
PlanManagerthat preloads the plan once and shares one presenter across screens. The SDK'sgetView()returns the same native View instance, soPlanMapViewhandles reparenting when the composable moves in the tree. - Hilt DI —
PlanManageris a@Singletoninjected into ViewModels. The presenter lifecycle is tied toMainActivity(preload inonCreate, dispose inonDestroy). - Plan preloading — the plan starts downloading in
MainActivity.onCreate()before any UI renders, so it's ready by the time the user navigates to a detail screen. - AnimatedVisibility — Compose animates the content above and below the map to collapse/expand. The map stays in the same composable tree position at all times (no detach/reattach of the WebView).
Code Walkthrough¶
MiniMapApplication.kt¶
Initializes the ExpoFP SDK at application startup. Must happen before any plan operations.
@HiltAndroidApp
class MiniMapApplication : Application() {
override fun onCreate() {
super.onCreate()
ExpoFpPlan.initialize(this)
}
}
MainActivity.kt¶
Starts plan preloading early so it downloads while the UI is being set up. Disposes the preloaded plan when the activity finishes.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var planManager: PlanManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Start plan preloading early so it downloads while the UI is being set up.
planManager.preloadPlan()
setContent {
MiniMapTheme {
AppNavigation()
}
}
}
override fun onDestroy() {
super.onDestroy()
// Dispose only when the activity is finishing (not on config changes).
if (isFinishing) {
planManager.dispose()
}
}
}
Key points:
- Preloading starts in
onCreateso the plan downloads during UI setup. dispose()is only called whenisFinishingis true — not on configuration changes like rotation.
PlanManager.kt¶
The central bridge between the SDK and the app. Manages the plan lifecycle, presenter access, and data queries.
@Singleton
class PlanManager @Inject constructor() {
private var planInfo: ExpoFpPreloadedPlanInfo? = null
private val _presenter = MutableStateFlow<IExpoFpPlanPresenter?>(null)
val presenter: StateFlow<IExpoFpPlanPresenter?> = _presenter.asStateFlow()
fun preloadPlan() {
if (planInfo != null) return
val params = listOf(
ExpoFpPlanParameter.NoOverlay(true),
ExpoFpPlanParameter.HideHeaderLogo(true)
)
planInfo = ExpoFpPlan.preloader.preloadPlan(
planLink = ExpoFpLinkType.ExpoKey(PLAN_KEY),
additionalParams = params
)
}
fun dispose() {
planInfo?.let { ExpoFpPlan.preloader.disposePreloadedPlan(it) }
_presenter.value = null
planInfo = null
}
fun obtainPresenter() {
val info = planInfo ?: return
_presenter.value = ExpoFpPlan.preloader.getPreloadedPlanPresenter(info)
}
fun getPlanStatusFlow(): StateFlow<ExpoFpPlanStatus>? = planInfo?.planStatusFlow
suspend fun getExhibitors(): ExpoFpResult<List<ExpoFpExhibitor>> {
val presenter = _presenter.value
?: return ExpoFpResult.failure(ExpoFpError.InternalError(message = "No presenter"))
return presenter.exhibitorsList()
}
suspend fun getBooths(): ExpoFpResult<List<ExpoFpBooth>> {
val presenter = _presenter.value
?: return ExpoFpResult.failure(ExpoFpError.InternalError(message = "No presenter"))
return presenter.boothsList()
}
companion object {
private const val PLAN_KEY = "demo"
const val ENTRANCE_BOOTH = "Entrance"
val HIDDEN_ELEMENTS = ExpoFpElementsVisibility(
controls = false, levels = false, header = false, overlay = false
)
}
}
Key points:
- The preloader API is a three-step lifecycle:
preloadPlan()→ observeplanStatusFlow→getPreloadedPlanPresenter()after Ready. disposePreloadedPlan()must be called explicitly — the SDK does not auto-cleanup.ExpoFpPlanParameter.NoOverlayandHideHeaderLogoare passed at preload time instead of callingsetElementsVisibilitylater.
PlanMapView.kt¶
Bridges the SDK's native Android View into Compose via AndroidView. Handles view reparenting since the SDK returns the same View instance for a given presenter.
@Composable
fun PlanMapView(
presenter: IExpoFpPlanPresenter,
modifier: Modifier = Modifier
) {
AndroidView(
factory = { context ->
val planView = presenter.getView()
(planView.parent as? ViewGroup)?.removeView(planView)
FrameLayout(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
addView(planView)
}
},
onRelease = { it.removeAllViews() },
modifier = modifier
)
}
ExhibitorListViewModel.kt¶
Observes the plan preloading lifecycle and fetches the exhibitor list once the plan is ready.
@HiltViewModel
class ExhibitorListViewModel @Inject constructor(
private val planManager: PlanManager
) : ViewModel() {
private val _uiState = MutableStateFlow(ExhibitorListUiState())
val uiState: StateFlow<ExhibitorListUiState> = _uiState.asStateFlow()
init {
observePlanStatus()
}
private fun observePlanStatus() {
viewModelScope.launch {
val statusFlow = planManager.getPlanStatusFlow() ?: run {
_uiState.update { it.copy(isLoading = false, error = "Plan not preloaded") }
return@launch
}
statusFlow.collect { status ->
when (status) {
is ExpoFpPlanStatus.Loading -> {
_uiState.update {
it.copy(isLoading = true, loadingProgress = status.percentage)
}
}
is ExpoFpPlanStatus.Initialization -> {
_uiState.update { it.copy(isLoading = true) }
}
is ExpoFpPlanStatus.Ready -> {
planManager.obtainPresenter()
loadExhibitors()
}
is ExpoFpPlanStatus.Error -> {
val message = when (val err = status.error) {
is ExpoFpError.InternalError -> err.message ?: "Internal error"
is ExpoFpError.DownloadingPlanError -> "Failed to download plan"
is ExpoFpError.InvalidExpoKey -> "Invalid expo key"
else -> "Failed to load plan"
}
_uiState.update { it.copy(isLoading = false, error = message) }
}
}
}
}
}
private fun loadExhibitors() {
viewModelScope.launch {
when (val result = planManager.getExhibitors()) {
is ExpoFpResult.Success -> {
_uiState.update {
it.copy(isLoading = false, exhibitors = result.value, error = null)
}
}
is ExpoFpResult.Failure -> {
_uiState.update {
it.copy(isLoading = false, error = "Failed to load exhibitors")
}
}
}
}
}
}
ExhibitorDetailViewModel.kt¶
Manages booth highlighting, map expand/collapse state, and directions routing.
@HiltViewModel
class ExhibitorDetailViewModel @Inject constructor(
private val planManager: PlanManager,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val exhibitorName: String = savedStateHandle.toRoute<ExhibitorDetail>().exhibitorName
private val _uiState = MutableStateFlow(ExhibitorDetailUiState(exhibitorName = exhibitorName))
val uiState: StateFlow<ExhibitorDetailUiState> = _uiState.asStateFlow()
val presenter: StateFlow<IExpoFpPlanPresenter?> = planManager.presenter
init {
resolveBoothName()
}
private fun resolveBoothName() {
viewModelScope.launch {
val exhibitors = planManager.getExhibitors().getOrNull() ?: return@launch
val exhibitor = exhibitors.find { it.name == exhibitorName } ?: return@launch
val firstBoothId = exhibitor.booths.firstOrNull() ?: return@launch
val booths = planManager.getBooths().getOrNull() ?: return@launch
val booth = booths.find { it.id == firstBoothId } ?: return@launch
val name = booth.externalId.ifEmpty { booth.name }
_uiState.update { it.copy(boothName = name) }
planManager.presenter.value?.selectBooth(name)
}
}
fun onScreenEnter() {
val presenter = planManager.presenter.value ?: return
presenter.setElementsVisibility(PlanManager.HIDDEN_ELEMENTS)
presenter.fitBounds()
}
fun onScreenExit() {
val presenter = planManager.presenter.value ?: return
presenter.selectBooth("")
presenter.selectRoute(emptyList())
presenter.fitBounds()
}
fun toggleMapExpanded() {
val currentState = _uiState.value
val presenter = planManager.presenter.value ?: return
if (currentState.isMapExpanded) {
if (currentState.isDirectionsActive) {
presenter.selectRoute(emptyList())
}
currentState.boothName?.let { presenter.selectBooth(it) }
presenter.fitBounds()
_uiState.update { it.copy(isMapExpanded = false, isDirectionsActive = false) }
} else {
presenter.setElementsVisibility(PlanManager.HIDDEN_ELEMENTS)
currentState.boothName?.let { presenter.selectBooth(it) }
_uiState.update { it.copy(isMapExpanded = true) }
}
}
fun toggleDirections() {
val boothName = _uiState.value.boothName ?: return
val presenter = planManager.presenter.value ?: return
val currentState = _uiState.value
if (currentState.isDirectionsActive) {
presenter.selectRoute(emptyList())
presenter.selectBooth(boothName)
_uiState.update { it.copy(isDirectionsActive = false) }
} else {
presenter.selectRoute(
from = ExpoFpRouteWaypoint.Booth(PlanManager.ENTRANCE_BOOTH),
to = ExpoFpRouteWaypoint.Booth(boothName)
)
_uiState.update { it.copy(isDirectionsActive = true) }
}
}
fun expandWithDirections() {
val presenter = planManager.presenter.value ?: return
presenter.setElementsVisibility(PlanManager.HIDDEN_ELEMENTS)
_uiState.update { it.copy(isMapExpanded = true, pendingDirections = true) }
}
fun consumePendingDirections() {
if (_uiState.value.pendingDirections) {
_uiState.update { it.copy(pendingDirections = false) }
toggleDirections()
}
}
}
Key points:
resolveBoothName()chains exhibitor → booth lookup to find the SDK booth identifier for highlighting and routing.expandWithDirections()defersselectRoute()until the expand animation completes via apendingDirectionsflag — calling it on a partially expanded viewport produces incorrect results.toggleDirections()is a toggle — it can both show and clear the route.
ExhibitorDetailScreen.kt¶
The detail screen with the inline mini-map and fullscreen expansion.
@Composable
fun ExhibitorDetailScreen(
onNavigateBack: () -> Unit,
viewModel: ExhibitorDetailViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val presenter by viewModel.presenter.collectAsStateWithLifecycle()
val isExpanded = uiState.isMapExpanded
DisposableEffect(Unit) {
viewModel.onScreenEnter()
onDispose { viewModel.onScreenExit() }
}
BackHandler(enabled = isExpanded) {
viewModel.toggleMapExpanded()
}
val cornerRadius by animateDpAsState(
targetValue = if (isExpanded) 0.dp else 16.dp,
label = "cornerRadius"
)
val horizontalPadding by animateDpAsState(
targetValue = if (isExpanded) 0.dp else 16.dp,
label = "horizontalPadding"
)
Column(
modifier = Modifier.fillMaxSize().navigationBarsPadding()
) {
// Top bar — hidden when expanded
AnimatedVisibility(visible = !isExpanded) {
TopAppBar(
title = { Text(uiState.exhibitorName) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
)
}
// Content above map — hidden when expanded
val contentVisibleState = remember { MutableTransitionState(!isExpanded) }
contentVisibleState.targetState = !isExpanded
LaunchedEffect(contentVisibleState.isIdle, contentVisibleState.currentState) {
if (contentVisibleState.isIdle && !contentVisibleState.currentState) {
viewModel.consumePendingDirections()
}
}
AnimatedVisibility(visibleState = contentVisibleState) {
Column(modifier = Modifier.padding(16.dp)) {
Text(uiState.exhibitorName, style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
Text(LOREM_IPSUM, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(16.dp))
}
}
// Map — stays in place, switches between fixed height and weight(1f)
Box(
modifier = (if (isExpanded) Modifier.weight(1f) else Modifier.height(280.dp))
.fillMaxWidth()
.padding(horizontal = horizontalPadding)
.clip(RoundedCornerShape(cornerRadius))
) {
presenter?.let { p ->
PlanMapView(presenter = p, modifier = Modifier.fillMaxSize())
}
// Block touch on mini-map (static preview)
if (!isExpanded) {
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
awaitPointerEventScope { while (true) { awaitPointerEvent() } }
})
}
// Expand / Directions buttons
if (presenter != null) {
Row(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
SmallFloatingActionButton(onClick = {
if (isExpanded) viewModel.toggleDirections()
else viewModel.expandWithDirections()
}) {
Icon(Icons.Default.Directions, contentDescription = null)
}
SmallFloatingActionButton(onClick = viewModel::toggleMapExpanded) {
Icon(
if (isExpanded) Icons.Default.CloseFullscreen
else Icons.Default.OpenInFull,
contentDescription = null
)
}
}
}
}
}
}
Key points:
- The map view stays in the same position in the composable tree —
AnimatedVisibilityhides/shows the content above it, and the map switches betweenModifier.height(280.dp)andModifier.weight(1f). - Touch interactions on the mini-map are blocked with
pointerInputconsuming all events — making it a static preview. MutableTransitionStatetracks when the collapse animation finishes to trigger deferredselectRoute()calls.BackHandlerintercepts the system back gesture when the map is expanded, collapsing it instead of navigating back.
SDK APIs Used¶
| API | Purpose |
|---|---|
ExpoFpPlan.initialize |
Initialize the SDK (call once in Application) |
ExpoFpPlan.preloader.preloadPlan |
Start asynchronous plan download |
getPreloadedPlanPresenter |
Obtain the presenter after plan is ready |
disposePreloadedPlan |
Release preloaded plan resources |
planStatusFlow |
StateFlow that emits plan loading status changes |
getView |
Get the native Android View for the plan |
exhibitorsList |
Fetch the list of exhibitors |
boothsList |
Fetch the list of booths |
selectBooth |
Highlight a booth by name or external ID |
selectRoute |
Draw a wayfinding path between two booths |
setElementsVisibility |
Hide/show default SDK UI controls |
fitBounds |
Reset the map zoom to show all content |