With Navigation 3, you have full control over your back stack, and navigating to and from destinations is as simple as adding and removing items from a list.
As with a number of the other Jetpack libraries, JetBrains maintain their own fork that works with Compose Multiplatform. In this article we’re going to outline how we can use Navigation 3 in the FantasyPremierLeague Compose Multiplatform sample. Note that all the code shown here is defined in commonMain in the project’s shared KMP code.
We firstly add the following Navigation 3 dependencies (using the JetBrains versions of the library). As well as the core ui dependency we’re also including the Material3 Adaptive Navigation3 library (we’ll show later in the article how that can be used to implement adaptive layouts).
1
2
3
4
5
6
androidxNavigation3UI = "1.0.0-alpha04"
androidxNavigation3Material = "1.3.0-alpha01"
androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3UI" }
androidx-navigation3-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "androidxNavigation3Material" }
1
2
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.material3.adaptive)
Next up we define the following keys/routes which will be used to map to particular content in the app. We’re also defining “top level” routes here which we’ll use in our NavigationBar implementation.
Note that we’re using @Serializable here to allow making the back stack persistent.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Serializable
private sealed interface Route
@Serializable
private sealed interface TopLevelRoute: Route {
val icon: ImageVector
val contentDescription: String
}
@Serializable
private data object PlayerList : TopLevelRoute {
override val icon = Icons.Default.Person
override val contentDescription = "Players"
}
@Serializable
private data class PlayerDetails(val playerId: Int) : Route
@Serializable
private data object FixtureList : TopLevelRoute {
override val icon = Icons.Filled.DateRange
override val contentDescription = "Fixtures"
}
@Serializable
private data object League : TopLevelRoute {
override val icon = Icons.AutoMirrored.Filled.List
override val contentDescription = "Leagues"
}
@Serializable
private data object Settings : Route
private val topLevelRoutes: List<TopLevelRoute> = listOf(PlayerList, FixtureList, League)
We then create our back stack and use NavDisplay to manage our app’s key/content mapping (along with navigating to and from destinations by adding and removing items from that back stack).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
val backStack: MutableList<Route> =
rememberSerializable(serializer = SnapshotStateListSerializer()) {
mutableStateListOf(PlayerList)
}
Scaffold(
bottomBar = { FantasyPremierLeagueBottomNavigation(topLevelRoutes, backStack) }
){
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<PlayerList> {
PlayerListView(
onPlayerSelected = { player ->
backStack.add(PlayerDetails(player.id))
},
onShowSettings = {
backStack.add(Settings)
}
)
}
entry<PlayerDetails> { key ->
val viewModel = koinViewModel<PlayerDetailsViewModel>()
viewModel.setPlayer(key.playerId)
PlayerDetailsView(viewModel, popBackStack = { backStack.removeLastOrNull() })
}
entry<FixtureList> { FixturesListView() }
entry<League> { LeagueListView() }
entry<Settings> { SettingsView { popBackStack = backStack.removeLastOrNull() } }
}
)
}
And finally this is how we setup our NavigationBar to use those top level routes mentioned earlier (again we navigate to particular content by just adding entries to our back stack).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Composable
private fun FantasyPremierLeagueBottomNavigation(
topLevelRoutes: List<TopLevelRoute>,
backStack: MutableList<Route>
) {
var selectedType by remember { mutableStateOf<TopLevelRoute>(PlayerList) }
NavigationBar {
topLevelRoutes.forEach { topLevelRoute ->
NavigationBarItem(
icon = {
Icon(
imageVector = topLevelRoute.icon,
contentDescription = topLevelRoute.contentDescription
)
},
selected = topLevelRoute == selectedType,
onClick = {
selectedType = topLevelRoute
backStack.add(topLevelRoute)
}
)
}
}
}
As mentioned earlier we can also support adaptive layouts using the Material3 Adaptive Navigation3 library. In our example we do this by firstly creating the appropriate SceneStrategy (ListDetailSceneStrategy in our case).
1
2
3
4
5
6
val windowAdaptiveInfo = currentWindowAdaptiveInfo()
val directive = remember(windowAdaptiveInfo) {
calculatePaneScaffoldDirective(windowAdaptiveInfo)
.copy(horizontalPartitionSpacerSize = 0.dp)
}
val listDetailStrategy = rememberListDetailSceneStrategy<Any>(directive = directive)
We then pass that strategy to NavDisplay along with now using the appropriate metadata values as shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategy = listDetailStrategy,
entryProvider = entryProvider {
entry<PlayerList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Choose a player from the list",
textAlign = TextAlign.Center
)
}
}
)
) {
PlayerListView(
onPlayerSelected = { player ->
backStack.add(PlayerDetails(player.id))
},
onShowSettings = { backStack.add(Settings) }
)
}
entry<PlayerDetails>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
val viewModel = koinViewModel<PlayerDetailsViewModel>()
viewModel.setPlayer(key.playerId)
PlayerDetailsView(
viewModel,
popBackStack = { backStack.removeLastOrNull() })
}
entry<FixtureList> { FixturesListView() }
entry<League> { LeagueListView() }
entry<Settings> { SettingsView { popBackStack = backStack.removeLastOrNull() } }
},
)
With these changes we now have an adaptive list/detail layout that adjusts accordingly as the window size changes (this screenshot is from the Compose for Desktop client but works in exactly the same way in the Android and iOS clients).

Note that several other navigation use cases are covered in the excellent Navigation 3 - Code recipes repository.
Featured in Kotlin Weekly Issue #486, Android Weekly #702 and jetc.dev Newletter Issue #291
Using Navigation 3 with Compose Multiplatform #KMP #CMP https://t.co/aeUR018DkU
— John O'Reilly (@joreilly) November 16, 2025
Your connected tools are now available in Claude on your mobile device.
— Anthropic (@AnthropicAI) July 25, 2025
Now you can access projects, create new docs, and complete work while on the go. pic.twitter.com/tqWuQ5r6Gc
The first thing we need to do is create a Google Cloud project that we can deploy our server to (using Cloud Run)….something we can do for example using the Cloud Resource Manager. In our case we created climatetrace-mcp and we set that as the active gcloud project as follows.
gcloud config set project climatetrace-mcpBefore we can run our server on Cloud Run we need to build the code, package as a container and deploy that container to Artifact Registry. We do that using the Jib Gradle Plugin as shown below.
libs.version.toml
1
2
3
4
5
6
7
8
[versions]
...
jib = "3.4.5"
[plugins]
...
jib = { id = "com.google.cloud.tools.jib", version.ref = "jib" }
build.gradle.kts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
plugins {
...
alias(libs.plugins.jib)
}
...
jib {
from.image = "docker.io/library/eclipse-temurin:21"
to {
image = "gcr.io/climatetrace-mcp/climatetrace-mcp-server"
}
container {
ports = listOf("8080")
mainClass = "McpServerKt"
}
}
We run the jib gradle task then which will create and deploy the container image.
./gradlew :mcp-server:jibNote the following is what the container is configured to run (this makes use of the mcp Ktor extension function from the Kotlin MCP SDK which sets up the required SSE support).
McpServer.kt
fun main() {
val server = configureMcpServer()
val port = System.getenv().getOrDefault("PORT", "8080").toInt()
embeddedServer(CIO, port, host = "0.0.0.0") {
mcp {
server
}
}.start(wait = true)
}And finally we can deploy that container to Cloud Run using the following command.
gcloud run deploy climatetrace-mcp --image=gcr.io/climatetrace-mcp/climatetrace-mcp-serverWe can now configure Claude Desktop for example to use our deployed MCP Server.

And now, with that server configured, we can also access from the Claude mobile apps.

We can also interact with the server using MCP Inspector.

The code shown here is part of the mcp-server module in the ClimateTraceKMP repository.
Featured in Kotlin Weekly Issue #470 and Android Weekly #686
Deploying a Kotlin-based remote MCP Server to Google Cloud Run https://t.co/IpgvQL4gdl
— John O'Reilly (@joreilly) July 27, 2025
(I finally succumbed to creating an AI generated image to represent the post...using Gemini in this case 😀)
agents module in the ClimateTrace Kotlin Multiplatform sample).
The first thing we need to do is add the following gradle dependencies to our project (0.2.0 being the latest version at time of writing this).
1
2
implementation("com.google.adk:google-adk:0.2.0")
implementation("com.google.adk:google-adk-dev:0.2.0")
ADK works with the following LLM providers and shown below is an example of creating a Gemini based model.
1
2
3
4
5
6
val model = Gemini(
"gemini-1.5-pro",
Client.builder()
.apiKey(apiKeyGoogle)
.build()
)
With the model created we can now create our AI agent. We’re also providing tools to the agent….more about that in a later section.
1
2
3
4
5
6
7
val agent = LlmAgent.builder()
.name(NAME)
.model(model)
.description("Agent to answer climate emissions related questions.")
.instruction("You are an agent that provides climate emissions related information. Use 3 letter country codes.")
.tools(tools)
.build()
The following then shows how we can run the agent with the given prompt.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fun main() {
val runner = InMemoryRunner(initAgent())
val session = runner
.sessionService()
.createSession(NAME, USER_ID)
.blockingGet()
val prompt =
"""
Get emission data for EU countries in 2024.
Use units of millions for the emissions data.
Show result in a grid of decreasing order of emissions.
""".trimIndent()
val userMsg = Content.fromParts(Part.fromText(prompt))
val events = runner.runAsync(USER_ID, session.id(), userMsg)
events.blockingForEach(Consumer { event: Event ->
event.content().get().parts().getOrNull()?.forEach { part ->
part.text().getOrNull()?.let { println(it) }
part.functionCall().getOrNull()?.let { println(it) }
part.functionResponse().getOrNull()?.let { println(it) }
}
if (event.errorCode().isPresent || event.errorMessage().isPresent) {
println("error: ${event.errorCode().get()}, ${event.errorMessage().get()}")
}
})
}
A Tool in this context “represents a specific capability provided to an AI agent, enabling it to perform actions and interact with the world beyond its core text generation and reasoning abilities”. Shown below are examples of creating both MCP and “Function Tools”.
The following shows an example of how we can create an MCP Server tool…the MCP Server in this case developed using the Kotlin MCP SDK.
1
2
3
4
5
6
val tools = McpToolset(
ServerParameters
.builder("java")
.args("-jar", "<path to climate trace mcp server jar file>", "--stdio")
.build()
).loadTools().join()
We can also provide what are known as function tools (just local funtions that provide some functionality to our AI agent). Here we need to call Kotlin suspend functions from those tools and, as such, are specifically using ADK’s Long Running Function Tools. These need to return an RxJava Single so we need to use the following dependency to allow us to wrap invocation of our Kotlin suspend functions with rxSingle.
1
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.10.2")
And these are the tools we’re using. The methods need to be static to be consumed by the Java based ADK code so we include in a companion object and annotate with @JvmStatic. Note were invoking shared KMP code here (ClimateTraceRepository) managed using the Koin DI framework.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ClimateTraceTool {
companion object {
val climateTraceRepository = koin.get<ClimateTraceRepository>()
@JvmStatic
fun getCountries(): Single<Map<String, String>> {
return rxSingle {
mapOf("countries" to climateTraceRepository.fetchCountries().toString())
}
}
@JvmStatic
fun getEmissions(countryCode: String, year: String): Single<Map<String, String>> {
return rxSingle {
mapOf("emissions" to climateTraceRepository.fetchCountryEmissionsInfo(countryCode, year).toString())
}
}
}
}
1
2
3
val getCountriesTool = LongRunningFunctionTool.create(ClimateTraceTool::class.java, "getCountries")
val getEmissionsTool = LongRunningFunctionTool.create(ClimateTraceTool::class.java, "getEmissions")
val tools = listOf(getCountriesTool, getEmissionsTool)
ADK also includes a developer web UI to help with agent develoment and debugging. It works by scanning a list of source folders provided to it for agents and this setup right now requires that we provide a Java file such as following.
1
2
3
public class Agent {
public static BaseAgent ROOT_AGENT = initAgent();
}
We’ve also created a gradle task to allow launching that dev UI.
1
2
3
4
5
6
7
tasks.register<JavaExec>("devUi") {
group = "application"
description = "Start the ADK Dev UI server"
mainClass.set("com.google.adk.web.AdkWebServer")
classpath = sourceSets["main"].runtimeClasspath
args = listOf("--adk.agents.source-dir=src/main/java")
}
Update: 19th September 2005
With ADK 0.3.0 the process of setting up ADK Dev UI has been simplified.

Featured in Android Weekly #683
Using Google's Agent Development Kit for Java from Kotlin code https://t.co/MABiUdhR6T
— John O'Reilly (@joreilly) July 11, 2025
Uses both MCP and local "function tools"....article includes some fun gotchas about calling suspend functions from those tools and working with the ADK Dev UI)! #BuildWithAI cc @meteatamel
Koog: a Kotlin-based framework designed to build and run AI agents entirely in idiomatic Kotlin (just announced at #KotlinConf keynote) https://t.co/TirxwuMRZ2
— John O'Reilly (@joreilly) May 22, 2025
In this somewhat contrived example our agent will use a prompt that (1) requests climate emission data, (2) generates Compose UI code (and preview) that shows that data and, (3) adds file containing that code to an Android Studio project. It will also use the MCP servers we configured as needed. Koog can work with several different LLMs and we’ll use Google Gemini in this example.
Koog is available through the following dependency
1
implementation("ai.koog:koog-agents:0.2.1")
The following then is all the code needed to create our initial basic AI agent (using Gemini LLM in this case). This is based on using a “Single-run agent” (Koog also supports creation of more complex workflow agents).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
fun main() = runBlocking {
val toolRegistry = createAndStartMCPServers()
val agent = AIAgent(
executor = simpleGoogleAIExecutor(apiKeyGoogle),
llmModel = GoogleModels.Gemini1_5Pro,
toolRegistry = toolRegistry
)
agent.run(
"""
Get emissions for France, Germany and Spain for 2023 and 2024.
Create Compose UI file using Material3 showing the emissions data in a grid
and add to the project in the composeApp module.
Use units of millions for the emissions data.
Also create a Compose preview.
""".trimIndent()
)
}
suspend fun createAndStartMCPServers(): ToolRegistry {
// ClimateTrace MCP Server
val processClimateTrace = ProcessBuilder
("java", "-jar",
"<path to climate trace mcp server jar file>",
"--stdio"
).start()
val transportClimateTrace = McpToolRegistryProvider.defaultStdioTransport(processClimateTrace)
val toolRegistryClimateTrace = McpToolRegistryProvider.fromTransport(transportClimateTrace)
// JetBrains MCP server
val processIntelliJ = ProcessBuilder(
"npx", "-y", "@jetbrains/mcp-proxy"
).start()
val transportIntelliJ = McpToolRegistryProvider.defaultStdioTransport(processIntelliJ)
val toolRegistryIntelliJ = McpToolRegistryProvider.fromTransport(transportIntelliJ)
return toolRegistryClimateTrace + toolRegistryIntelliJ
}
This is the output when we run this code showing the various MCP server tool invocations made.
1
2
3
4
5
6
7
Tool called: tool get-emissions, args Args(arguments={"countryCode":"FRA","year":"2023"})
Tool called: tool get-emissions, args Args(arguments={"countryCode":"FRA","year":"2024"})
Tool called: tool get-emissions, args Args(arguments={"countryCode":"DEU","year":"2023"})
Tool called: tool get-emissions, args Args(arguments={"year":"2024","countryCode":"DEU"})
Tool called: tool get-emissions, args Args(arguments={"countryCode":"ESP","year":"2023"})
Tool called: tool get-emissions, args Args(arguments={"year":"2024","countryCode":"ESP"})
Tool called: tool create_new_file_with_text, args Args(arguments={"text":"```kotlin\npackage com.example.composeapp\n\nimport android.os.Bundle\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.example.composeapp.ui.theme.ComposeAppTheme\n\n\nclass MainActivity : ComponentActivity() {\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n setContent {\n ComposeAppTheme {\n // A surface container using the 'background' color from the theme\n EmissionsGrid()\n\n }\n }\n }\n}\n\n@Composable\nfun EmissionsGridItem(country: String, year: Int, emissions: Double) {\n Card(\n modifier = Modifier\n .fillMaxWidth()\n .padding(4.dp),\n colors = CardDefaults.cardColors(\n containerColor = MaterialTheme.colorScheme.surfaceVariant,\n ),\n\n ) {\n Column(\n modifier = Modifier.padding(16.dp)\n\n ) {\n\n Text(text = country, style = MaterialTheme.typography.titleMedium)\n Text(\n text = \"Year: $year\",\n style = MaterialTheme.typography.bodyMedium,\n color = Color.Gray\n\n )\n Text(\n text = \"Emissions (millions): ${emissions}\",\n style = MaterialTheme.typography.bodyLarge\n )\n }\n }\n}\n\n@Composable\nfun EmissionsGrid() {\n val emissionsData = listOf(\n EmissionsData(\"France\", 2023, 367.2409),\n EmissionsData(\"France\", 2024, 366.7807),\n EmissionsData(\"Germany\", 2023, 690.8744),\n EmissionsData(\"Germany\", 2024, 671.735),\n EmissionsData(\"Spain\", 2023, 273.4409),\n EmissionsData(\"Spain\", 2024, 277.5746)\n\n )\n Column {\n emissionsData.forEach { data ->\n EmissionsGridItem(data.country, data.year, data.emissions)\n }\n }\n}\n\n\ndata class EmissionsData(val country: String, val year: Int, val emissions: Double)\n\n\n@Preview(showBackground = true)\n@Composable\nfun DefaultPreview() {\n ComposeAppTheme {\n EmissionsGrid()\n }\n}\n```","pathInProject":"composeApp/src/main/java/com/example/composeapp/EmissionsView.kt"})
And this is example then of the Compose code (and associated preview) that was added to the Android Studio project (using the mcp-jetbrains plugin).

Koog can work with several different LLM providers (specifically Google, OpenAI, Anthropic, OpenRouter, and Ollama) and following shows how we can create an agent that uses for example OpenAI. You can also create prompt executors that works with multiple LLM providers.
1
2
3
4
5
val agent = AIAgent(
executor = simpleOpenAIExecutor(openAIApiKey),
llmModel = OpenAIModels.Chat.GPT4o,
toolRegistry = toolRegistry
)
Along with MCP based tools, Koog also supports having “local” annotation-based custom tools and the following is an example of such a tool (also merged to ClimateTraceKMP project).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@LLMDescription("Tools for getting climate emission information")
class ClimateTraceTool : ToolSet {
val climateTraceRepository = koin.get<ClimateTraceRepository>()
@Tool
@LLMDescription("Get the list of countries")
suspend fun getCountries(): List<String> {
val countries = climateTraceRepository.fetchCountries()
return countries.map { "${it.name}, ${it.alpha3}" }
}
@Tool(customName = "getEmissions")
@LLMDescription("Get the emission data for a country for a particular year.")
suspend fun getEmissions(
@LLMDescription("country code") countryCode: String,
@LLMDescription("year") year: String
): List<String> {
return climateTraceRepository.fetchCountryEmissionsInfo(countryCode, year).map {
it.emissions.co2.toString()
}
}
}
With that implemented we can now switch between adding this or the MCP based tool to the tool registry.
1
2
3
4
5
6
7
8
9
10
11
12
13
suspend fun createToolSetRegistry(): ToolRegistry {
val processClimateTrace = ProcessBuilder("java", "-jar",
"<path to climate trace mcp server jar file>", "--stdio"
).start()
val transportClimateTrace = McpToolRegistryProvider.defaultStdioTransport(processClimateTrace)
val toolRegistryClimateTrace = McpToolRegistryProvider.fromTransport(transportClimateTrace)
val localToolSetRegistry = ToolRegistry { tools(ClimateTraceTool().asTools()) }
// Can use either local toolset or one based on MCP server
//return toolRegistryClimateTrace
return localToolSetRegistry
}
Featured in Kotlin Weekly Issue #465 and Android Weekly #681
Wrote short article about use of Koog for developing Kotlin based AI agents (combined with a number of MCP servers).
— John O'Reilly (@joreilly) June 22, 2025
The example included is based on use of #BuildWithGemini LLM (really had to use that with #GoogleIOConnect fast approaching 😀) https://t.co/rd7X5vFWvp
We’re making use of the following dependency (for the Kotlin MCP SDK)
1
2
mcp = "0.5.0"
mcp-kotlin = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp" }
Having added that we can now create our MCP Server instance.
1
2
3
4
5
6
7
8
9
10
11
val server = Server(
Implementation(
name = "ClimateTrace MCP Server",
version = "1.0.0"
),
ServerOptions(
capabilities = ServerCapabilities(
tools = ServerCapabilities.Tools(listChanged = true)
)
)
)
That can be used in turn to add the following tools that we’ll be exposing from the server (MCP also supports exposing resources and prompts).
get-countries
1
2
3
4
5
6
7
8
9
server.addTool(
name = "get-countries",
description = "List of countries"
) {
val countries = climateTraceRepository.fetchCountries()
CallToolResult(
content = countries.map { TextContent("${it.name}, ${it.alpha3}") }
)
}
get-emissions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
server.addTool(
name = "get-emissions",
description = "List emission info for a particular country",
inputSchema = Tool.Input(
properties = JsonObject(
mapOf(
"countryCode" to JsonPrimitive("string"),
"year" to JsonPrimitive("date"),
)
),
required = listOf("countryCode", "year")
)
) { request ->
val countryCode = request.arguments["countryCode"]
val year = request.arguments["year"]
if (countryCode == null || year == null) {
return@addTool CallToolResult(
content = listOf(TextContent("The 'countryCode' and `year` parameters are required."))
)
}
val countryEmissionInfo = climateTraceRepository.fetchCountryEmissionsInfo(
countryCode = countryCode.jsonPrimitive.content,
year = year.jsonPrimitive.content
)
CallToolResult(
content = countryEmissionInfo.map { TextContent(it.emissions.co2.toString()) }
)
}
Note that we’re making use of ClimateTraceRepository from our shared KMP code to fetch the data needed.
MCP servers are typically accessed over stdio or sse. In the stdio case (as used by Claude Desktop for example) we run the following (wrapping around server instance we created above).
1
2
3
4
5
6
val transport = StdioServerTransport(
System.`in`.asInput(),
System.out.asSink().buffered()
)
server.connect(transport)
In the case of sse we use Ktor server as follows.
1
2
3
4
5
embeddedServer(CIO, host = "0.0.0.0", port = port) {
mcp {
server
}
}.start(wait = true)
Now we can use Claude Desktop to test out the integration. We add our MCP server by selecting Edit Config under developer settings. This will allow editing of claude_desktop_config.json to which we’ll add the following. This will trigger Claude to start our server when it starts up (using jar file created using the gradle :mcp-server:shadowJar task`).
{
"mcpServers": {
"kotlin-climatetrace": {
"command": "java",
"args": [
"-jar",
"/Users/joreilly/dev/github/ClimateTrace/mcp-server/build/libs/serverAll.jar",
"--stdio"
]
}
}
}
With this in place we can now make climate emission related queries in Claude that will make use, as needed, of the tools we’re exposing from our MCP Server. For example here we’re asking to “Graph France’s emissions from 2020 to 2024”. Note that Claude knew to initially call get-countries to get the country code it needed for call to get-emissions.

Claude will also obtain other data it needs as shown for the query below (e.g. it separately fetched population data for those years in response to the query and combined with data served from the MCP server).

Note that this exploration was heavily influenced by this very nice tutorial from the official Kotlin-AI-Examples github repo.
Featured in Kotlin Weekly Issue #461 and Android Weekly #678
Kotlin MCP 💜 Kotlin Multiplatform
— John O'Reilly (@joreilly) May 31, 2025
Wrote a short article on using the Kotlin MCP SDK in a KMP project https://t.co/J2RU9fmlyA
The general availability of Vertex AI in Firebase was announced recently and in this article we’ll show how to use the associated Android and iOS SDKs in a Compose/Kotlin Multiplatform project. The code shown is included in the VertexAI-KMP-Sample repository.
Vertex AI in Firebase is now generally available.
— Firebase (@Firebase) October 21, 2024
You can confidently use our Kotlin, Swift, Web and Flutter SDKs to release your AI features into production, knowing they're backed by Google Cloud and Vertex AI quality standards.
Discover more ↓ https://t.co/SoOfAT9SOz
We initially performed the following steps
google-services.json and GoogleService-Info.plist files were downloaded then and added to the Android and iOS projects.
We’re making use of the following libraries in shared code and made related changes shown below to the build config for the KMP module.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[versions]
...
firebaseBom = "33.12.0"
koin = "4.0.4"
kotlinx-serialization = "1.8.0"
markdownRenderer = "0.27.0"
coil = "3.1.0"
[libraries]
...
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-vertexai = { module = "com.google.firebase:firebase-vertexai" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
[plugins]
...
googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
plugins {
...
alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.googleServices)
}
androidMain.dependencies {
...
implementation(project.dependencies.platform(libs.firebase.bom))
implementation(libs.firebase.vertexai)
}
commonMain.dependencies {
...
implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.markdown.renderer)
implementation(libs.koin.core)
implementation(libs.koin.compose.viewmodel)
implementation(libs.coil.compose)
}
We’re making use of 3 Vertex AI features in this sample (illustrated in the screenshots below)
Markdown CMP library)
To support Android and iOS specific implementations of this functionality we created the following interface in shared (commonMain code)
1
2
3
4
5
interface GenerativeModel {
suspend fun generateTextContent(prompt: String): String?
suspend fun generateJsonContent(prompt: String): String?
suspend fun generateImage(prompt: String): ByteArray?
}
This is implemented on Android as follows (in androidMain source set in shared KMP module) where we make use of the Vertex AI Android SDK. We’re also defining the schema here that Vertex will use when generating the json response.
For this sample we’re using a specific schema that supports a range of prompts that return a list of people (for example as shown in screenshots above). The response is parsed using the Kotlinx Serializaton library and rendered as a simple list in our shared Compose Multiplatform UI code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class GenerativeModelAndroid: GenerativeModel {
private val jsonSchema = Schema.array(
Schema.obj(
mapOf(
"name" to Schema.string(),
"country" to Schema.string()
)
)
)
override suspend fun generateTextContent(prompt: String): String? {
val generativeModel = Firebase.vertexAI.generativeModel(
modelName = "gemini-1.5-flash"
)
return generativeModel.generateContent(prompt).text
}
override suspend fun generateJsonContent(prompt: String): String? {
val generativeModel = Firebase.vertexAI.generativeModel(
modelName = "gemini-1.5-flash",
generationConfig = generationConfig {
responseMimeType = "application/json"
responseSchema = jsonSchema
}
)
return generativeModel.generateContent(prompt).text
}
override suspend fun generateImage(prompt: String): ByteArray? {
val imageModel = Firebase.vertexAI.imagenModel(
modelName = "imagen-3.0-generate-002"
)
val imageResponse = imageModel.generateImages(prompt)
return if (imageResponse.images.isNotEmpty()) {
imageResponse.images.first().data
} else {
null
}
}
}
On iOS this is implemented in the following Swift code (using the Vertex AI iOS SDK in this case). We’re also defining the above mentioned schema here. A future enhancement would be to define this schema in some generic format in shared code and then translate that in the Android and iOS implementations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class GenerativeModelIOS: ComposeApp.GenerativeModel {
static let shared = GenerativeModelIOS()
let vertex = VertexAI.vertexAI()
let jsonSchema = Schema.array(
items: .object(
properties: [
"name": .string(),
"country": .string()
]
)
)
func generateTextContent(prompt: String) async throws -> String? {
let model = vertex.generativeModel(
modelName: "gemini-1.5-flash"
)
return try await model.generateContent(prompt).text
}
func generateJsonContent(prompt: String) async throws -> String? {
let model = vertex.generativeModel(
modelName: "gemini-1.5-flash",
generationConfig: GenerationConfig(
responseMIMEType: "application/json",
responseSchema: jsonSchema
)
)
return try await model.generateContent(prompt).text
}
func generateImage(prompt: String) async throws -> KotlinByteArray? {
let model = vertex.imagenModel(modelName: "imagen-3.0-generate-002")
let response = try await model.generateImages(prompt: prompt)
guard let image = response.images.first else {
return nil
}
let imageData = image.data
return imageData.toByteArray()
}
}
That iOS implementation of GenerativeModel is passed down to shared code when initialising Koin. Note that this is where we’re initialsing Firebase on iOS as well.
1
2
3
4
5
6
7
8
9
10
11
12
13
@main
struct iOSApp: App {
init() {
FirebaseApp.configure()
initialiseKoin(generativeModel: GenerativeModelIOS.shared)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
That iOS specific instance of GenerativeModel is added to the Koin object graph and is what will be injected in to our shared view model when running on iOS.
1
2
3
4
5
6
7
8
fun initialiseKoin(generativeModel: GenerativeModel) {
startKoin {
modules(
commonModule,
module { single<GenerativeModel> { generativeModel } }
)
}
}
The Android version of Koin initialisation in turn adds GenerativeModelAndroid to the object graph.
1
2
3
4
5
6
7
8
fun initialiseKoin() {
startKoin {
modules(
commonModule,
module { single<GenerativeModel> { GenerativeModelAndroid() } }
)
}
}
We initialise Firebase and Koin in our main Android application class.
1
2
3
4
5
6
7
class VertexAIKMPApp : Application() {
override fun onCreate() {
super.onCreate()
FirebaseApp.initializeApp(this)
initialiseKoin()
}
}
We invoke the above APIs in the following shared view model (we’re using the KMP Jetpack ViewModel library here). That includes the generateContent function which the Compose UI code calls with the text prompt and a flag indicating whether to generate a json response or not. If generateJson is set we also parse the response and return the structured data to the UI code which renders as a basic list (as shown in the screenshots above). We’re also invoking generateImage from here as well.`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class GenerativeModelViewModel(private val generativeModel: GenerativeModel) : ViewModel() {
val uiState = MutableStateFlow<GenerativeModelUIState>(GenerativeModelUIState.Initial)
fun generateContent(prompt: String, generateJson: Boolean) {
uiState.value = GenerativeModelUIState.Loading
viewModelScope.launch {
try {
uiState.value = if (generateJson) {
val response = generativeModel.generateJsonContent(prompt)
if (response != null) {
val entities = Json.decodeFromString<List<Entity>>(response)
GenerativeModelUIState.Success(entityContent = entities)
} else {
GenerativeModelUIState.Error("Error generating content")
}
} else {
val response = generativeModel.generateTextContent(prompt)
GenerativeModelUIState.Success(textContent = response)
}
} catch (e: Exception) {
GenerativeModelUIState.Error(e.message ?: "Error generating content")
}
}
}
fun generateImage(prompt: String) {
uiState.value = GenerativeModelUIState.Loading
viewModelScope.launch {
uiState.value = try {
val imageData = generativeModel.generateImage(prompt)
GenerativeModelUIState.Success(imageData = imageData)
} catch (e: Exception) {
GenerativeModelUIState.Error(e.message ?: "Error generating content")
}
}
}
}
Finally, the following is taken from the shared Compose UI code that’s used to render the response.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
is GenerativeModelUIState.Success -> {
if (uiState.entityContent != null) {
LazyColumn {
items(uiState.entityContent) { item ->
ListItem(
headlineContent = { Text(item.name)},
supportingContent = { Text(item.country) }
)
}
}
} else if (uiState.textContent != null) {
Markdown(uiState.textContent)
} else if (uiState.imageData != null) {
AsyncImage(
model = ImageRequest
.Builder(LocalPlatformContext.current)
.data(uiState.imageData)
.build(),
contentDescription = prompt,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth()
)
}
}
Featured in Android Weekly #647 and Kotlin Weekly Issue #432
Using Vertex AI in a Compose/Kotlin Multiplatform project https://t.co/k6P1cO5zly
— John O'Reilly (@joreilly) October 27, 2024
Circuit is summarised in the official documentation as “a simple, lightweight, and extensible framework for building Kotlin applications that’s Compose from the ground up”. It strongly enables a unidirectional data flow (UDF) approach that is based on the following principles.
Circuit’s core components are its Presenter and Ui interfaces.
- A Presenter and a Ui cannot directly access each other. They can only communicate through state and event emissions.
- UIs are compose-first.
- Presenters are also compose-first. They do not emit Compose UI, but they do use the Compose runtime to manage and emit state.
- Both Presenter and Ui each have a single composable function.
- In most cases, Circuit automatically connects presenters and UIs.
- Presenter and Ui are both generic types, with generics to define the UiState types they communicate with.
- They are keyed by Screens. One runs a new Presenter/Ui pairing by requesting them with a given Screen that they understand.
As mentioned, what’s outlined in this article is based on changes made to the BikeShare KMP/CMP sample. That project makes use of the CityBikes API to show bike share networks and associated bike availability in different countries around the world. The following shows screenshots for the country, network, and station list screens in the Compose for Desktop client.

In the case of the country list for example we have the following key Circuit components
CountryListScreenCountryListPresenter (and associated CountryListPresenterFactory factory)CountryListUi (and associated CountryListUiFactory)A Screen, as mentioned above, is used as the key for a particular Circuit Presenter/Ui pairing. In this case here it’s also used to encapsulate the state that a presenter can emit to the Ui and the events that the Ui can send to the presenter.
1
2
3
4
5
6
7
8
9
10
11
@Parcelize
data object CountryListScreen : Screen {
data class State(
val countryList: List<Country>,
val eventSink: (Event) -> Unit
) : CircuitUiState
sealed class Event : CircuitUiEvent {
data class CountryClicked(val countryCode: String) : Event()
}
}
The following is the factory for creating CountryListPresenter presenters, keyed as mentioned by CountryListScreen.
1
2
3
4
5
6
7
8
9
10
11
@Inject
class CountryListPresenterFactory(
private val presenterFactory: (Navigator) -> CountryListPresenter,
) : Presenter.Factory {
override fun create(screen: Screen, navigator: Navigator, context: CircuitContext): Presenter<*>? {
return when (screen) {
CountryListScreen -> presenterFactory(navigator)
else -> null
}
}
}
And then this is the implementation for CountryListPresenter. This includes a single composable function that’s used to create the state that’s emitted to our UI (note this is making use of Compose Runtime as opposed to Compose UI)…it also handles any events sent to it from the UI (e.g. CountryClicked).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Inject
class CountryListPresenter(
@Assisted private val navigator: Navigator,
private val cityBikesRepository: CityBikesRepository
) : Presenter<CountryListScreen.State> {
@Composable
override fun present(): CountryListScreen.State {
val groupedNetworkList by cityBikesRepository.groupedNetworkList.collectAsState()
val countryCodeList = groupedNetworkList.keys.toList()
val countryList = countryCodeList.map { countryCode -> Country(countryCode, getCountryName(countryCode)) }
.sortedBy { it.displayName }
return CountryListScreen.State(countryList) { event ->
when (event) {
is CountryListScreen.Event.CountryClicked -> navigator.goTo(NetworkListScreen(event.countryCode))
}
}
}
}
Finally we have the UI component. This gets notified of state emissions from the associated presenter (CountryListPresenter) and sends any UI events to that presenter (in this example indicating that a country was selected in the list).
1
2
3
4
5
6
7
8
9
10
11
12
@Composable
fun CountryListUi(state: CountryListScreen.State, modifier: Modifier = Modifier) {
Scaffold(modifier = modifier, topBar = { TopAppBar(title = { Text("Countries") }) }) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(state.countryList) { country ->
CountryView(country) {
state.eventSink(CountryListScreen.Event.CountryClicked(country.code))
}
}
}
}
}
We also have the following in our “root” composable that uses Circuit to launch that initial CountryListScreen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
typealias BikeShareApp = @Composable () -> Unit
@Inject
@Composable
fun BikeShareApp(circuit: Circuit) {
MaterialTheme {
val backStack = rememberSaveableBackStack(root = CountryListScreen)
val navigator = rememberCircuitNavigator(backStack) {}
CircuitCompositionLocals(circuit) {
NavigableCircuitContent(navigator = navigator, backStack = backStack)
}
}
}
The following then shows how these components are configured using the kotlin-inject DI framework (we’re also showing the setup here for the network and station list related components). Note also use of that framework’s support for multi-binding to allow setting up the set of presenter and ui factories.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
abstract val bikeShareApp: BikeShareApp
@IntoSet
@Provides
fun bindCountryListPresenterFactory(factory: CountryListPresenterFactory): Presenter.Factory = factory
@IntoSet
@Provides
fun bindCountryListUiFactory(factory: CountryListUiFactory): Ui.Factory = factory
@IntoSet
@Provides
fun bindNetworkListPresenterFactory(factory: NetworkListPresenterFactory): Presenter.Factory = factory
@IntoSet
@Provides
fun bindNetworkListUiFactory(factory: NetworkListUiFactory): Ui.Factory = factory
@IntoSet
@Provides
fun bindStationListPresenterFactory(factory: StationListPresenterFactory): Presenter.Factory = factory
@IntoSet
@Provides
fun bindStationListUiFactory(factory: StationListUiFactory): Ui.Factory = factory
@Provides
fun provideCircuit(
uiFactories: Set<Ui.Factory>,
presenterFactories: Set<Presenter.Factory>
): Circuit = Circuit.Builder()
.addUiFactories(uiFactories)
.addPresenterFactories(presenterFactories)
.build()
Note that a recent release of Circuit added support for kotln-inject code generation that removes need for some of the boilerplate code shown here. Hope to take a look soon at using that!
Featured in Android Weekly #643
Using Circuit with kotlin-inject in a Kotlin/Compose Multiplatform project https://t.co/jncGH5HYDU
— John O'Reilly (@joreilly) October 5, 2024
A short article outlining some of changes made to the BikeShare #KMP sample to make of the really nice Circuit framework.
Starting off, the following were the changes made to libs.version.toml and build.gradle.kts. Note also that we’re setting the generateCompanionExtensionsoption…this allows a more convenient way of creating the generated kotlin-inject components.
libs.version.toml
1
2
3
4
5
kotlininject = "0.7.1"
kotlininject-compiler = { module = "me.tatarka.inject:kotlin-inject-compiler-ksp", version.ref = "kotlininject" }
kotlininject-runtime = { module = "me.tatarka.inject:kotlin-inject-runtime", version.ref = "kotlininject" }
build.gradle.kts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
commonMain.dependencies {
...
implementation(libs.kotlininject.runtime)
}
...
ksp {
arg("me.tatarka.inject.generateCompanionExtensions", "true")
}
dependencies {
add("kspAndroid", libs.kotlininject.compiler)
add("kspIosX64", libs.kotlininject.compiler)
add("kspIosArm64", libs.kotlininject.compiler)
add("kspIosSimulatorArm64", libs.kotlininject.compiler)
add("kspJvm", libs.kotlininject.compiler)
}
We then define the shared dependencies in SharedApplicationComponent.kt as shown below. We’re using a single interface given the scope of this project but typically, in larger projects, the dependencies would get grouped into different interfaces.
As we’ll see later, we’re also going to create platform specific components that subclass this interface. This will allow both creating platform specific versions of the dependencies (for example getHttpClientEnginewhich is used to create Ktor client engine for each platform) and also to allow clients to pass in objects that might be needed for each platform (though not doing that yet in this project).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
interface SharedApplicationComponent {
val countriesViewModel: CountriesViewModelShared
val networksViewModel: NetworksViewModelShared
val stationsViewModel: StationsViewModelShared
val repository: CityBikesRepository
val cityBikesApi: CityBikesApi
val json: Json
@Provides get() = Json { isLenient = true; ignoreUnknownKeys = true; useAlternativeNames = false }
val realm: Realm
@Provides get() {
val config = RealmConfiguration.create(schema = setOf(NetworkDb::class))
return Realm.open(config)
}
@Provides
fun getHttpClientEngine(): HttpClientEngine
@Provides
fun httpClient(): HttpClient = createHttpClient(getHttpClientEngine(), json)
}
fun createHttpClient(httpClientEngine: HttpClientEngine, json: Json) = HttpClient(httpClientEngine) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
}
The way those dependencies get injected then in various classes is through use of @Inject annotation as shown here (we also mark in this case as @Singleton)
1
2
3
4
@Inject @Singleton
class CityBikesRepository(val cityBikesApi: CityBikesApi,val realm: Realm) {
...
}
The Android specific version of the kotlin-inject component (AndroidApplicationComponent.kt) is shown below (created in androidMain in the shared KMP module). In this case we’re using that component to add dependencies for various Composable functions that we want to be passed particular dependencies. We also define the Android specific version of the Ktor client engine here.
1
2
3
4
5
6
7
8
9
10
11
@Component
@Singleton
abstract class AndroidApplicationComponent: SharedApplicationComponent {
abstract val countryListScreen: CountryListScreen
abstract val networkListScreen: NetworkListScreen
abstract val stationsScreen: StationsScreen
override fun getHttpClientEngine() = Android.create()
companion object
}
To allow dependencies to be passed to Composable functions we need to use approach shown below (more info on that in this page). Note also the use of @Assisted for parameters that we’ll be using when those composable functions are used.
1
2
3
4
5
6
7
8
typealias CountryListScreen = @Composable ((country: Country) -> Unit) -> Unit
@Inject
@Composable
fun CountryListScreen(viewModel: CountriesViewModelShared, @Assisted countrySelected: (country: Country) -> Unit) {
val countryList by viewModel.countryList.collectAsState()
...
}
The following are the changes needed then in the Android client module. We firstly add code to create the component in BikeShareApplication.kt
1
2
3
4
5
6
7
class BikeShareApplication : Application() {
val component: AndroidApplicationComponent by lazy {
AndroidApplicationComponent.create()
}
...
}
That gets used then in MainActivity.kt as shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val applicationComponent = (applicationContext as BikeShareApplication).component
setContent {
BikeShareTheme {
BikeShareApp(applicationComponent)
}
}
}
}
The android app is using Jetpack Navigation and this is an example of how the appropriate Composable function was retrieved from the component and used then in our NavHost.
1
2
3
4
5
6
7
8
9
10
NavHost(navController, startDestination = Screen.CountryListScreen.title) {
val countryListScreen = applicationComponent.countryListScreen
composable(Screen.CountryListScreen.title) {
countryListScreen {
navController.navigate(Screen.NetworkListScreen.title + "/${it.code}")
}
}
...
}
For iOS we also have a platform specific component (IosApplicationComponent as shown below) which is created in iosMain in the shared KMP module. As with Android, we’re also creating the platform specific version of the Ktor client engine here.
1
2
3
4
5
6
7
8
@Component
@Singleton
abstract class IosApplicationComponent: SharedApplicationComponent {
override fun getHttpClientEngine() = Darwin.create()
companion object
}
In our SwiftUI client we create that component on startup and pass to ContentView.
1
2
let applicationCompoonent = IosApplicationComponent.companion.create()
let contentView = ContentView(applicationCompoonent: applicationCompoonent)
The following then shows example in ContentView of how we’re using that component to look up one of the shared view models (done in this way also to allow StateFlows in the shared view models to be observed in SwiftUI….using KMP-ObservableViewModel library).
1
2
3
4
5
6
7
8
9
10
11
12
13
struct ContentView : View {
let applicationCompoonent: IosApplicationComponent
@ObservedViewModel var viewModel: CountriesViewModelShared
init(applicationCompoonent: IosApplicationComponent) {
self.applicationCompoonent = applicationCompoonent
self.viewModel = applicationCompoonent.countriesViewModel
}
var body: some View {
...
}
}
Featured in Android Weekly #633 and Kotlin Weekly Issue #418
Wrote a short article outlining changes made to add kotlin-inject to the BikeShare Kotlin Multiplatform sample. https://t.co/44VDe6X9t9 #KMP
— John O'Reilly (@joreilly) July 27, 2024
Touchlab:🌴Summer Streamin' Series 🏖️
— Touchlab (@TouchlabHQ) June 24, 2024
🌟 Multiplatform Compose + SwiftUI = The Native App Future
📅 Livestream - Friday, June 28th 2024
🔊 @faogustavo, @DevSrSouza & @kpgalligan
🔂 #KotlinConf'24 CodeLab
https://t.co/2nRQNurBfV
PeopleInSpace is a basic Kotlin Multiplatform sample that shows the list of people in space along with the position of the ISS…and it’s the latter that we’ll be focussing on here. This project had previously only used SwiftUI for the iOS UI (along with Compose on Android) but with the changes outlined here it’s now using the following for the ISS Position screen shown below.
This is perhaps somewhat contrived for this basic example but wanted to explore how this pattern of UI reuse could work as I believe it’s one that many apps might benefit from.

We’ll now outline changes needed to allow this arrangement. Taking the iOS client as an example the following shows the areas we described above.

We’re using in this case a standard SwiftUI TabView to display the navigation bar shown at the bottom of the screen.
1
2
3
4
5
6
7
8
9
10
TabView {
PeopleListScreen()
.tabItem {
Label("People", systemImage: "person")
}
ISSPositionScreen()
.tabItem {
Label("ISS Position", systemImage: "location")
}
}
And the following is the implementation of that ISSPositionScreen. Note that we’re making use of a shared Kotlin
view model (ISSPositionViewModel) and that will be passed
down through various levels so that the data exposed from it (the current position of the ISS) can be observed in each component.
Note that we’re also using SwiftUI’s navigation bar for this screen.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct ISSPositionScreen: View {
@State var viewModel = ISSPositionViewModel()
var body: some View {
NavigationView {
VStack {
ISSPositionContentViewController(viewModel: viewModel)
}
.navigationBarTitle(Text("ISS Position"))
.navigationBarTitleDisplayMode(.inline)
}
}
}
That in turn consumes our shared Compose Multiplatform code by using the following UIViewControllerRepresentable implementation
to wrap that code.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct ISSPositionContentViewController: UIViewControllerRepresentable {
let viewModel: ISSPositionViewModel
func makeUIViewController(context: Context) -> UIViewController {
SharedViewControllers().ISSPositionContentViewController(
viewModel: viewModel,
nativeViewFactory: iOSNativeViewFactory.shared
)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
}
We also importantly pass down following NativeViewFactory implementation which will allow that shared Compose code in turn
to include our SwiftUI map component. Note that this approach is heavily based on the Touchlab sample
mentioned earlier.
1
2
3
4
5
6
7
8
class iOSNativeViewFactory : NativeViewFactory {
static var shared = iOSNativeViewFactory()
func createISSMapView(viewModel: ISSPositionViewModel) -> UIViewController {
let mapView = NativeISSMapView(viewModel: viewModel)
return UIHostingController(rootView: mapView)
}
}
The following is the implementation of ISSPositionContentViewController in shared KMP code. It uses Compose’s
ComposeUIViewController to wrap ISSPositionContent which is the Composable that shows the content area of our screen.
1
2
3
4
5
6
7
object SharedViewControllers {
fun ISSPositionContentViewController(viewModel: ISSPositionViewModel, nativeViewFactory: NativeViewFactory) = ComposeUIViewController {
CompositionLocalProvider(LocalNativeViewFactory provides nativeViewFactory) {
ISSPositionContent(viewModel)
}
}
}
And shown below is that ISSPositionContent function (this is used on both iOS and Android).
It observes the ISS position (from the StateFlow in ISSPositionViewModel), updates some text to show
that position and then invokes ISSMapView (passing in the view model).
Note also use of collectAsStateWithLifecycle from JetBrain’s KMP version of the Jetpack Lifecycle library. You
can see the effect of this by looking at logs in Xcode and noting that the ISS position polling stops when
you navigate to say different tab in the iOS client.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
expect fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel)
@Composable
fun ISSPositionContent(viewModel: ISSPositionViewModel) {
val position by viewModel.position.collectAsStateWithLifecycle()
Column {
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Latitude = ${position.latitude}")
Text(text = "Longitude = ${position.longitude}")
}
Spacer(Modifier.height(16.dp))
ISSMapView(Modifier.fillMaxHeight().fillMaxWidth(), viewModel)
}
}
We use Kotlin Multiplatform’s expect/actual mechanism to define implementations of ISSMapView for each
platform. The following shows that implementation for iOS (there’s also an implementation for Android that uses
the osmandroid map library).
Here we make use of Compose’s UIKitViewController (and LocalNativeViewFactory we passed in earlier) to
include our SwiftUI based component that shows the map.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
actual fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel) {
MapKitView(
modifier = modifier,
viewModel = viewModel,
)
}
@Composable
internal fun MapKitView(
modifier: Modifier,
viewModel: ISSPositionViewModel
) {
val factory = LocalNativeViewFactory.current
UIKitViewController(
modifier = modifier,
factory = {
factory.createISSMapView(viewModel)
}
)
}
And this is that SwiftUI component to display the map (using MapKit) and show live updates of the ISS position. It does this
by observing viewModel.position (from the shared Kotlin view model) using the SKIE library.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct NativeISSMapView : View {
var viewModel: ISSPositionViewModel
var body: some View {
VStack {
Observing(viewModel.position) { issPosition in
let issCoordinatePosition = CLLocationCoordinate2D(latitude: issPosition.latitude, longitude: issPosition.longitude)
let regionBinding = Binding<MKCoordinateRegion>(
get: {
MKCoordinateRegion(center: issCoordinatePosition, span: MKCoordinateSpan(latitudeDelta: 150, longitudeDelta: 150))
},
set: { _ in }
)
Map(coordinateRegion: regionBinding, showsUserLocation: true,
annotationItems: [ Location(coordinate: issCoordinatePosition) ]) { (location) -> MapPin in
MapPin(coordinate: location.coordinate)
}
}
}
}
}
Featured in Kotlin Weekly Issue #414 and Android Weekly #630
Exploring New Worlds of UI sharing possibilities in PeopleInSpace using Compose Multiplatform https://t.co/5fPnIial0V
— John O'Reilly (@joreilly) June 30, 2024
Finally updated PeopleInSpace to include example of use of Compose Multiplatform and this post outlines changes made (heavily inspired by @TouchlabHQ's sample)
Wow, lots of AI and Gemini at #GoogleIO today, but we couldn't pass up the opportunity to also include some #JetpackReleaseNotes with Lifecycle 2.8.0 and Paging 3.3.0 having their first KMP stable releases! Plus, ViewPager2 1.1.0 and Fragment 1.7.1!https://t.co/vyCuwEI9vC
— Ian Lake (@ianhlake) May 15, 2024
Morty is a Kotlin Multiplatform sample that demonstrates use of GraphQL in shared KMP code using the Apollo Kotlin library and includes Jetpack Compose and SwiftUI clients (based on https://github.com/Dimillian/MortyUI SwiftUI project). The following are screenshots of the Android and iOS clients.

The initial changes involved just moving the Jetpack Paging common dependency and existing PagingSource classes from the
Android module to the shared KMP one. For example CharactersDataSource shown below which uses the repository class to fetch data for
a particular page from the GraphQL backend (the repository makes use in turn of the Apollo Kotlin library).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CharactersDataSource(private val repository: MortyRepository) : PagingSource<Int, CharacterDetail>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CharacterDetail> {
val pageNumber = params.key ?: 0
val charactersResponse = repository.getCharacters(pageNumber)
val characters = charactersResponse.results.mapNotNull { it?.characterDetail }
val prevKey = if (pageNumber > 0) pageNumber - 1 else null
val nextKey = charactersResponse.info.next
return LoadResult.Page(data = characters, prevKey = prevKey, nextKey = nextKey)
}
override fun getRefreshKey(state: PagingState<Int, CharacterDetail>): Int? {
return null
}
}
That Android module had also included view models where Pager instances had been created.
That code, along with those View Models, were also moved to shared code (making use of
the KMP-ObservableViewModel library
to share the view models across the platforms)
1
2
3
4
5
6
7
8
9
open class CharactersViewModel(): ViewModel(), KoinComponent {
private val repository: MortyRepository by inject()
val charactersFlow: Flow<PagingData<CharacterDetail>> = Pager(PagingConfig(pageSize = 20)) {
CharactersDataSource(repository)
}.flow.cachedIn(viewModelScope.coroutineScope)
...
}
The open question then was what was needed in the shared code to expose that data to the iOS SwiftUI client and thankfully received following suggestion from Ian Lake!
I’d be interested if you’re able to hook up the (newly public) PagingDataPresenter to something like UICollectionViewDiffableDataSource to get Paging hooked up to a SwiftUI based UI. In theory, you should have everything you need to get that working!
These are the changes added then to the view model to make us of PagingDataPresenter. This code hooks up that class to the
Pager one shown above. Note use of that getElement function below. We need to call this from the SwiftUI client as per
following comnent from Ian to replicate functionality in
LazingPagingItems that also calls charactersPagingDataPresenter.get().
Yes, that’s how it is supposed to work (and the big difference between peek() and get()) - get() is what sends the (internal) view port hints that is what triggers the automatic loading as you scroll down/up beyond the data already loaded.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private val charactersPagingDataPresenter = object : PagingDataPresenter<CharacterDetail>() {
override suspend fun presentPagingDataEvent(event: PagingDataEvent<CharacterDetail>) {
updateCharactersSnapshotList()
}
}
@NativeCoroutinesState
val charactersSnapshotList = MutableStateFlow<ItemSnapshotList<CharacterDetail>>(viewModelScope, charactersPagingDataPresenter.snapshot())
init {
viewModelScope.coroutineScope.launch {
charactersFlow.collectLatest {
charactersPagingDataPresenter.collectFrom(it)
}
}
}
private fun updateCharactersSnapshotList() {
charactersSnapshotList.value = charactersPagingDataPresenter.snapshot()
}
fun getElement(index: Int): CharacterDetail? {
return charactersPagingDataPresenter.get(index)
}
The following is the SwiftUI code to show the list of characters. It makes use of CharactersViewModel from the shared
KMP code and also functionality provided by the KMP-ObservableViewModel
library to allow a MutableStateFlow from the shared code to appear as a standard observable Swift property (in this case for charactersSnapshotList).
Note the call to getElement as mentioned above. I’m not certain there isn’t a better way to do this and will update the
article if I receive any feedback about that.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct CharactersListView: View {
@StateViewModel var viewModel = CharactersViewModel()
var body: some View {
List {
ForEach(viewModel.charactersSnapshotList.indices, id: \.self) { index in
if let character = viewModel.getElement(index: Int32(index)) {
CharactersListRowView(character: character)
}
}
}
.navigationTitle("Characters")
}
}
The Android Compose code is more or less the same as it was before. The only difference is use of that same CharactersViewModel used by the
iOS SwiftUI client.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable
fun CharactersListView(characterSelected: (character: CharacterDetail) -> Unit) {
val viewModel: CharactersViewModel = koinInject()
val lazyCharacterList = viewModel.charactersFlow.collectAsLazyPagingItems()
LazyColumn {
items(
count = lazyCharacterList.itemCount,
key = lazyCharacterList.itemKey { it.id }
) { index ->
val character = lazyCharacterList[index]
character?.let {
CharactersListRowView(character, characterSelected)
}
}
}
}
Featured in Android Weekly #623
Consuming Jetpack Paging KMP code in SwiftUI and Compose clients https://t.co/iqAu2ukKcN
— John O'Reilly (@joreilly) May 17, 2024
As mentioned in article, not 100% certain about SwiftUI implementation here but will update if/when I find better way (and thanks again to @ianhlake for pointers)