Android and iOS Public Transportation Timetable App with MapLibre Compose Part 2.
If you followed Part 1 of my tutorial you should have a MapLibre Compose application with a map component and an empty BottomSheet. The map…
Android and iOS Public Transportation Timetable App with MapLibre Compose Part 2.

If you followed Part 1 of my tutorial you should have a MapLibre Compose application with a map component and an empty BottomSheet. The map has one Marker with a custom shaped popup card. In Part 2 you will learn how to:
- Configure platform-specific HTTP client engines in Ktor, OkHttp (Android) and Darwin (iOS)
- Fetch GTFS data from the Föli REST API.
- Parse Kotlin data objects into a GeoJSON FeatureCollection via SymbolLayer
- Implement a conditional SymbolLayer filter to highlight the selected bus stop
- Handle camera animations and boundaries on marker clicks
- Pass marker state callbacks from the map component to a BottomSheetScaffold
Read tutorial Part 1 here:
Configuring the HTTP Client engines
In order to fetch anything from Föli’s TSJL transit API, the application needs an HTTP Client engine. Ktor is perfect for this because it has Compose Multiplatform integration. Always refer to official documentations and choose latest versions when installing dependencies and libraries. You also need Kotlinx coroutines for Android.
Ktor offers platform specific HTTP client engines. Use OkHttp on Android, and Darwin on iOS. Ktor also has CIO client engine which works on Android and iOS but it currently supports HTTP/1.x only, and crashes the app while parsing JSON responses despite offering the same content negotiation and serialization plugins as the other client engines.
On top of client core and client engines, also install Content negotiation and serialization plugin and JSON serializer.
Create a file HttpClient.kt for the HTTP client in the commonMain directory, and put the expected declaration in it.
import io.ktor.client.HttpClient
expect val client: HttpClient
Your IDE should automatically suggest and create the files with actual declarations. You should have HttpClient.android.kt file in the androidMain directory and HttpClient.ios.kt file in the iosMain directory.
On top of the useful HttpTimeout and Logging, you should also install UserAgent and add a header to the HTTP requests. Föli API policies ask clients to use User-Agents with headers. I have included no-public in the header because this application is private for now, and I don’t want it to be “published on some listing”, whatever that means.
The ContentNegotiation plugin allows you to specify what kind of data you expect to receive from the server with HTTP requests, and it serializes/deserializes the content in JSON format. The isLenient setting enables the lenient mode which makes the parser more liberal to malformed input, such as unquoted keys and string values.
The HttpClient on the iOS side is similar to Android, except for the client engine Darwin and the engine{} block where you configure the request.
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
// import io.ktor.client.engine.darwin.Darwin on iosMain
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.UserAgent
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
// HttpClient(Darwin) on iosMain
actual val client: HttpClient = HttpClient(OkHttp) {
install(HttpTimeout) {
socketTimeoutMillis = 60_000
requestTimeoutMillis = 60_000
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
install(UserAgent) {
agent = "MapLibreProject Android" // MapLibreProject iOS
}
defaultRequest {
url {
protocol = URLProtocol.HTTPS
host = "data.foli.fi"
}
header("MapLibreProject-Android-Header", "no-public")
// header("MapLibreProject-iOS-Header", "no-public")
}
install(ContentNegotiation) {
json(Json {
prettyPrint = false
isLenient = true
ignoreUnknownKeys = true
explicitNulls = false
})
}
/* on iosMain
engine {
configureRequest {
setAllowsCellularAccess(true)
}
}
*/
}
Föli’s TSJL — transit API
The host for the HTTP client’s default requests is data.foli.fi. The app requires data from three different APIs via this same host.
The GTFS API provides static data about the bus stop locations, route colors, and lines lists (i.e. a list of lines that drive by this specific bus stop). Every HTTP Get request to this API will be made exactly once. Therefore, no ViewModel is needed with this one.
The SIRI API provides real-time data about vehicle locations and timetables. The user will interact with this API sending multiple requests, and it requires a ViewModel.
The GEOJSON API provides data about Föli’s services. I will display three POI (Points of Interest) layers fetched from this API. The rememberGeoJson function with Uri GeoJson data class provided by MapLibre Compose will take care of this one.

Screenshot of the application with 3 API data sources
Putting the bus stops on the map
When the app launches it needs to fetch 3659 bus stop locations by sending a HTTP Get request to https://data.foli.fi/gtfs/stops. The response looks like this.
{
"1": {
"stop_code": "1",
"stop_name": "Turun satama (Silja)",
"stop_lat": 60.43496999999999985675458447076380252838134765625,
"stop_lon": 22.219660000000001076614353223703801631927490234375,
"zone_id": "F\u00d6LI",
"stop_timezone": "Europe\/Helsinki"
},
"10": {
"stop_code": "10",
"stop_name": "Sairashuoneenpuisto",
"stop_lat": 60.44418999999999897454472375102341175079345703125,
"stop_lon": 22.252330000000000609361450187861919403076171875,
"zone_id": "F\u00d6LI",
"stop_timezone": "Europe\/Helsinki"
},
...
}
The serialization plugin will take care of the umlauts. Each stop has a unique stop_code, but some stops share the same name.
Create a file with a data class for the bus stop. There’s no need to include the stop code from the response twice. The relevant values from the response will be mapped and parsed into GeoJson Features.
import kotlinx.serialization.Serializable
@Serializable
data class Stop(
val stop_code: String,
val stop_name: String,
val stop_lat: Double,
val stop_lon: Double,
)
Create a file for GTFS API/IMPL. I recommend separating these files from the App files to their own directory. The GTFSApiImpl.kt file has two suspend functions. The getStops() fetches all the stops and returns them in a map of String and Stop. The getStopStatus() function returns the HTTP response status code.
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
suspend fun getStops(): Map<String, Stop> {
return org.example.project.client.get("/gtfs/stops"){
method = HttpMethod.Get
}.body()
}
suspend fun getStopStatus(): HttpStatusCode {
return org.example.project.client.get("/gtfs/stops"){
method = HttpMethod.Get
}.status
}
Now these functions can be called in the MapComponent.kt file.
Last time the MapComponent() looked like this.
@Composable
fun MapComponent() {
val camera =
rememberCameraState(
firstPosition =
CameraPosition(
target = Position(latitude = 60.45195547084046, longitude = 22.267010954960753),
zoom = 15.0
)
)
val styleState = rememberStyleState()
var selectedFeature by remember { mutableStateOf<Feature<Geometry, JsonObject?>?>(null) }
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// MapLayer
MaplibreMap(
baseStyle = BaseStyle.Uri(Res.getUri("files/style.json")),
cameraState = camera,
styleState = styleState,
zoomRange = 6.8f..17f,
onMapClick = { position, dpOffset->
selectedFeature = null
ClickResult.Pass
},
options =
MapOptions(
ornamentOptions = OrnamentOptions.OnlyLogo,
gestureOptions = GestureOptions.Standard
)
){
// SymbolLayer
val markerSource = rememberGeoJsonSource(GeoJsonData.JsonString(markerJson))
SymbolLayer(
id = "bus-stop",
source = markerSource,
iconImage = image((markerIcon), drawAsSdf = true),
iconColor = const(Color.Blue),
iconSize = const(3.0f),
iconAllowOverlap = const(true),
iconAnchor = const(SymbolAnchor.Center),
onClick = { features ->
selectedFeature = features.firstOrNull()
ClickResult.Consume
}
)
}
// UI Layer
if (selectedFeature != null) {
selectedFeature?.let { feature ->
PopUpCard(
feature = feature,
cameraState = camera,
onDismiss = {
selectedFeature = null
}
)
}
}
}
}
Create a function for parsing the bus stop data into GeoJson features. The getStopsAsGeoJson() returns the bus stop data as FeatureCollection in JSON string format.
import org.example.project.data.Stop
import org.example.project.data.getStops
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.Point
import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.toJson
suspend fun getStopsAsGeoJson(): String{
// Send HTTP Get request and map the response
val mapping: Map<String, Stop> = getStops()
val features = mapping.values.map{ value ->
Feature(
geometry =
Point(
Position(
longitude = value.stop_lon,
latitude = value.stop_lat,
)
),
properties =
mapOf(
"stop_code" to (value.stop_code),
"stop_name" to (value.stop_name)
),
)
}
return FeatureCollection(features).toJson()
}
Import the function in the MapComponent.kt file. Suspend functions can only be called from a coroutine. The app displays a progress indicator while it processes the bus stop location data.
The CircularProgressIndicator will be displayed while isLoading is true, and httpStatus is zero. After the getStopsAsGeoJson() is called the isLoading is set to false. The getStopStatus().value returns integer value of the HTTP status code.
private var stopData by mutableStateOf(featureCollectionOf().toJson())
private var httpStatus by mutableStateOf(0)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapLibreComponent(){
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
withContext(Dispatchers.Default) {
try {
httpStatus = getStopStatus().value
stopData = getStopsAsGeoJson()
isLoading = false
} catch(e: Exception){
e.printStackTrace()
}
}
}
// camera, styleState, selectedFeature
// BoxWithConstraints
// MapLayer
...
//SymbolLayer
val markerSource = rememberGeoJsonSource(GeoJsonData.JsonString(stopData))
...
// UI Layer
if (isLoading && httpStatus == 0) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally){
CircularProgressIndicator(
modifier = Modifier.width(64.dp),
color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
// PopUpCard
...
Now the app looks like this when launching.

Launching the app
Adding a highlight layer
You might notice that the overlapping icons make it difficult to see which bus stop was actually selected. That’s why adding another SymbolLayer with distinct icon halo color on top of the bus stop layer is a good idea. I set the iconHaloColor black on the second layer for the selected icon. I also added white iconHaloColor on the first SymbolLayer because seeing the map through the bus icon window is confusing.
Both SymbolLayers share the same source but each must have a unique id. The first SymbolLayer id is bus-stop and the second SymbolLayer id is highlight-layer. The highlight layer also requires a filter because otherwise every bus stop icon halo will be turned black when clicking one stop. The bus stops have unique stop codes which is the filtering condition. The SymbolLayer’s filter accepts an Expression with boolean value. Expression is one of MapLibre Compose’s interfaces. I have included the necessary imports in the code block.
Create a new variable for the selected bus stop. On the first SymbolLayer set the string value of stop_code to selectedStop. On the second SymbolLayer the filter checks if the stop_code of the selected Feature matches the selectedStop. The second SymbolLayer doesn’t need its own onClick function.
import org.maplibre.compose.expressions.dsl.Feature.get
import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.eq
// SymbolLayer
var selectedStop by remember { mutableStateOf<String?>("") }
val markerSource = rememberGeoJsonSource(GeoJsonData.JsonString(stopData))
SymbolLayer(
id = "bus-stop",
source = markerSource,
iconImage = image((markerIcon), drawAsSdf = true),
iconSize = const(1.0f),
iconColor = const(Color(0xFF5985E1)),
iconHaloColor = const(Color.White),
iconHaloWidth = const(18.dp),
iconAllowOverlap = const(true),
onClick = { features ->
selectedFeature = features.firstOrNull()
selectedStop = selectedFeature?.getStringProperty("stop_code")
ClickResult.Consume
}
)
SymbolLayer(
id = "highlight-layer",
source = markerSource,
iconImage = image((markerIcon), drawAsSdf = true),
iconSize = const(1.1f),
iconColor = const(Color(0xFF789DE5)),
iconHaloColor = const(Color.Black),
iconHaloWidth = const(19.dp),
iconAllowOverlap = const(true),
filter = get("stop_code").asString().eq(const(selectedStop ?: "")),
)
The highlight layer has bigger icon and halo sizes to make the selected bus stop stand out more. The layers also have slightly different shades for the icon color. The screenshot below displays the difference between selected bus stop (highlight layer) and the other bus stops.

Selected bus stop with black halo
The SymbolLayer has many more styling options not covered in this tutorial.
Centering the camera on marker click
MapLibre Compose doesn’t automatically center the camera when you click the Markers. The animation below demonstrates how popup cards near the edges of the screen get partially cut off. Because this app has so many markers in such small area, centering the camera on every marker click would be a bit much and could possibly give the user motion sickness.

App without camera centering function
In MapLibre Compose the function for moving and centering the camera is animateTo provided by CameraState. The suspend function requires LaunchedEffect and it will be restarted every time the value of feature.geometry changes.
The animateTo() function takes parameters CameraPosition and Duration. The default animation duration is 300 milliseconds. If you don’t want to adjust it, you can leave the parameter unspecified. CameraPosition needs to know the target Position and the zoom level. The zoom level was set 15.0 for the first position. If you leave the zoom value empty in the CameraPosition it will automatically set the zoom level to 15.0.
Save the current zoom level to currentZoom value. Then you set the selected feature’s coordinates as target position.
val currentZoom = camera.position.zoom
val targ = (feature.geometry as Point).coordinates
If you were to center the camera on every marker click, this would be enough for calling the animateTo() function. Next you need to configure boundaries for deciding when to move the camera.
All layers in the MapComponent() are inside a BoxWithConstraints composable. The LocalDensity API provides the composable’s width and height as density-independent pixels (dp). Configure horizontal and vertical margins. Since the PopUpCard is placed horizontally in the middle of the marker, there’s no need for left and right margin.
The PopUpCard is positioned on top of the marker meaning the top margin needs to be bigger than the bottom margin. The values for the margins can only be determined by testing the app.
It is also worth mentioning that SymbolLayer’s iconSize is a float value but the icon halo size is in dp. For example, iconSize = const(3.0f) means the original icon size will be multiplied by 3. I strongly recommend using svg images instead of png (or jpg) unless you want to deal with device specific vertical PopUpCard placement. If you don’t have access to vector images, I recommend setting the DpSize inside the image function instead. For example:
iconImage = image((markerIcon), size = DpSize(width = 24.dp, height = 24.dp), drawAsSdf = true),
You get the selected marker’s DpOffset with the screenLocationFromPosition function. Then you create a boolean value needsUpdate and call the camera.animateTo() function if needsUpdate is true.
import androidx.compose.ui.platform.LocalDensity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapLibreComponent(){
....
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val density = LocalDensity.current
val screenWidth = with(density) { maxWidth }
val screenHeight = with(density) { maxHeight }
// MapLayer
// SymbolLayer
// UI Layer
// CircularProgressIndicator
if (selectedFeature != null) {
selectedFeature?.let { feature ->
LaunchedEffect(feature.geometry) {
val currentZoom = camera.position.zoom
val targ = (feature.geometry as Point).coordinates
val screenPos = camera.projection?.screenLocationFromPosition(targ)
val horizontalMargin = 50.dp
val topMargin = 70.dp
val bottomMargin = 30.dp
val screenX = screenPos?.x
val screenY = screenPos?.y
val needsUpdate = screenX!! < horizontalMargin ||
screenX > (screenWidth - horizontalMargin) ||
screenY!! < topMargin ||
screenY > (screenHeight - bottomMargin)
if (needsUpdate) {
camera.animateTo(
CameraPosition(
target = targ,
zoom = currentZoom,
),
// optional:
duration = 500.milliseconds
)
}
}
PopUpCard(
feature = feature,
cameraState = camera,
onDismiss = {
selectedFeature = null
}
)
}
}
}
}
Unfortunately, MapLibre Compose doesn’t have ‘easeTo’ and ‘flyTo’ options for the animation like MapLibre GL JS does. The animateTo() has that certain bounciness to it and it looks especially bad on that low quality emulator. Luckily modern smartphones have a lot more computational power than emulators and running the app on a real device allows you to use the default duration or even set it to 1 millisecond. (If you set the duration to zero, it will crash the app.)
You must figure out the most suitable animation duration by testing different options, just like with the screen margins.

Centering the camera on marker click
Connecting the BottomSheet on the map
The bus stop specific real-time timetables will be fetched from the SIRI API with the stop_code in the URL and displayed on the BottomSheet. In order to tell which stop_code to search with in the BottomSheetContent() component there needs to be callbacks sending information from the Map Component. Add onMarkerClick callback to the MapComponent() and save the selectedFeature in it. Remember to include the ? for null values when no bus stop has been selected yet.
fun MapComponent(
onMarkerClick: (feature:Feature<Geometry, JsonObject?>) -> Unit
){
......
SymbolLayer(
id = "bus-stop",
....
onClick = { features ->
selectedFeature = features.firstOrNull()
selectedStop = selectedFeature?.getStringProperty("stop_code")
selectedFeature?.let { onMarkerClick(it) }
ClickResult.Consume
}
)
Create a file for BottomSheetContent(). The function takes a Feature as a parameter, and it has onDismiss callback. The bus stop specific timetables will be displayed in a LazyColumn with itemsIndexed extension function and Card composable.
The code block below demonstrates how to access the string values of the selected Feature in the BottomSheetContent.kt file.
(Setting the contents of Text() inside the element is not good practice.)
@Composable
fun BottomSheetContent(
feature: Feature<Geometry, JsonObject?>?,
onDismiss: () -> Unit
) {
val lazyListState = rememberLazyListState()
val overScrollEffect = rememberOverscrollEffect()
LazyColumn(
modifier = Modifier.padding(bottom = 20.dp),
contentPadding = PaddingValues(horizontal = 1.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
state = lazyListState,
userScrollEnabled = true,
reverseLayout = false,
overscrollEffect = overScrollEffect,
) {
if (feature == null) {
item {
Column(Modifier.padding(top = 5.dp)) {
Text(
text = "Select a bus stop",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
stickyHeader {
feature?.let {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(2.dp, Color.Black),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${it.getStringProperty("stop_name")}\n" +
"${it.getStringProperty("stop_code")}",
fontWeight = FontWeight.Bold, fontSize = 20.sp,
textAlign = TextAlign.Start,
modifier = Modifier
.padding(
start = 8.dp,
top = 6.dp,
end = 2.dp,
bottom = 6.dp
)
.weight(3.0f)
)
}
}
}
}
}
}
}
⚠️ Disclaimer ⚠️ If you implement the BottomSheet like this, you must disable screen rotation. When the user rotates their screen it will set the Feature null and crash the app as you implement more functions. Proper configuration for state preservation and ViewModel will be addressed in the next part of the tutorial.
Finally import BottomSheetContent() to App.kt file. Add the necessary coroutine scope for dismissing/hiding the BottomSheet. Also create variable for selectedMarker and a callback onMarkerClick for the MapComponent().
fun App() {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.PartiallyExpanded
)
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = sheetState
)
var selectedMarker by remember {
mutableStateOf<Feature<Geometry, JsonObject?>?>(null)
}
val onMarkerClick: (feature: Feature<Geometry, JsonObject?>) -> Unit = {
markerdata -> selectedMarker = markerdata
}
MaterialTheme {
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetSwipeEnabled = true,
sheetPeekHeight = 260.dp,
sheetMaxWidth = Dp.Unspecified,
sheetContent = {
// Timetables will be displayed here
BottomSheetContent(
feature = selectedMarker,
onDismiss = {
coroutineScope.launch {
selectedMarker = null
scaffoldState.bottomSheetState.hide()
}
}
)
},
topBar = {
TopAppBar(
colors = topAppBarColors(
titleContentColor = Color.Black,
),
title = {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
text = "Föli Zone"
)
}
)
}, // Main content
content = { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
MapComponent(onMarkerClick = onMarkerClick)
}
},
modifier = Modifier.fillMaxWidth()
)
}
}
The screenshots below demonstrate how the app looks like at launch and after clicking a bus stop. The stickyHeader with Text() element gets added to the BottomSheet after the Feature has a non null value.

First screenshot without selected marker, second with selected marker info on BottomSheet
Coming up on next
- Real-Time Data & State Management: Integrating the SIRI API with Koin and ViewModels to fetch active, live timetables.
- Native GeoJSON Layers: Fetching and rendering custom POI data on top of the base map style.
- Dynamic Filtering: Implementing UI controls to let users instantly filter dense timetables by line number on the fly.
- User Tracking: Implementing a live location puck to display and track the user’s real-world position.
Thanks for reading! Check out my project on GitHub
Sources:
메타데이터
- post_id
- 1d10f1cb613c
- slug
- android-and-ios-public-transportation-timetable-app-with-maplibre-compose-part-2-1d10f1cb613c
- url
- https://medium.com/@minnanord/android-and-ios-public-transportation-timetable-app-with-maplibre-compose-part-2-1d10f1cb613c
- canonical_url
- https://medium.com/@minnanord/android-and-ios-public-transportation-timetable-app-with-maplibre-compose-part-2-1d10f1cb613c
- author_url
- https://medium.com/@minnanord
- status
- ok
- fetched_at
- 2026-07-30 14:02:18