Skip to content

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.

Video

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.

Three-screen mini-map flow

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 PlanManager that preloads the plan once and shares one presenter across screens. The SDK's getView() returns the same native View instance, so PlanMapView handles reparenting when the composable moves in the tree.
  • Hilt DIPlanManager is a @Singleton injected into ViewModels. The presenter lifecycle is tied to MainActivity (preload in onCreate, dispose in onDestroy).
  • 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 onCreate so the plan downloads during UI setup.
  • dispose() is only called when isFinishing is 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() → observe planStatusFlowgetPreloadedPlanPresenter() after Ready.
  • disposePreloadedPlan() must be called explicitly — the SDK does not auto-cleanup.
  • ExpoFpPlanParameter.NoOverlay and HideHeaderLogo are passed at preload time instead of calling setElementsVisibility later.

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() defers selectRoute() until the expand animation completes via a pendingDirections flag — 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 — AnimatedVisibility hides/shows the content above it, and the map switches between Modifier.height(280.dp) and Modifier.weight(1f).
  • Touch interactions on the mini-map are blocked with pointerInput consuming all events — making it a static preview.
  • MutableTransitionState tracks when the collapse animation finishes to trigger deferred selectRoute() calls.
  • BackHandler intercepts 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