The increasing number of libraries that support Compose Multiplatform has been an important factor in the widening interest in that ecosystem. One of those libraries is the KoalaPlot charting library which I had been trying out in the Android and Desktop Compose clients that are part of the FantasyPremierLeague Kotlin Multiplatform (KMP) sample. With KoalaPlot’s recent support for Compose for iOS I thought I’d take a look at creating shared Compose code for all 3 platforms (sharing the chart UI and also the screen containing it).

FantasyPremierLeague

FantasyPremierLeague is, as mentioned, a Kotlin Multiplatform sample. It shows information about a fantasy football (or soccer if you’re in the US 😁 ) game for the Premier League in England. Part of the information shown is statistics for a particular player including a chart showing how many points the player had in previous years. It’s that screen that we’re looking at sharing here (screenshots from iOS, Android and Desktop clients shown below)

FantasyPremierLeague screenshot


Sharing compose code

We’ll firstly outline below how we added support to an existing KMP module for sharing compose code and then show the “plumbing” needed to consume that shared code in a SwiftUI based iOS app.

Updating existing KMP module

The following are changes needed to allow using an existing KMP common module to also share Compose UI code. Another option is to create a separate module for this but at least for this project that seemed like unnecessary overhead and opted for the simpler approach of using a single module.

1
2
3
4
5
6
7
8
9
10
11
12
13
// add to plugins section
id("org.jetbrains.compose")


// add to commonMain
implementation(compose.ui)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.components.resources)

implementation(libs.koalaplot)
implementation(libs.image.loader)

At this point we can now add Compose code that we want to share to this module. This will work as is for Android and Desktop (JVM) but, as we’ll describe next, there’s some additional plumbing needed to support iOS.

Consuming shared Compose code in SwiftUI

The following is the SwiftUI code in our iOS app that will allow us to consume the shared Compose code for that player details screen and also notify that shared code from SwiftUI when the player history data has been retrieved in response to async request (or if it was potentially subsequently updated).

This technique btw for updating state is based on this really nice article by Guilherme Delgado. Note also that we’re pulling in the shared Compose code for the content area of that screen….SwiftUI is stil being used for the top/bottom bar.

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
struct PlayerDetailsViewShared: UIViewControllerRepresentable {
    var player: Player
    @Binding var playerHistory: [PlayerPastHistory]

    func makeUIViewController(context: Context) -> UIViewController {
        return SharedViewControllers().playerDetailsViewController(player: player)
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        SharedViewControllers().updatePlayerHistory(playerHistory: playerHistory)
    }
}


struct PlayerDetailsView: View {
    @ObservedObject var viewModel: FantasyPremierLeagueViewModel
    var player: Player

    @State var playerHistory = [PlayerPastHistory]()

    var body: some View {
        PlayerDetailsViewShared(player: player, playerHistory: $viewModel.playerHistory)
        .task {
            await viewModel.getPlayerStats(playerId: player.id)
        }
        .navigationTitle(player.name)
    }
}

In our shared module then we add following in iosMain source set (contains methods called from PlayerDetailsViewShared in our SwiftUI code above).

1
2
3
4
5
6
7
8
9
10
11
12
13
object SharedViewControllers {

    private val playerHistoryState = MutableStateFlow<List<PlayerPastHistory>>(emptyList())

        fun playerDetailsViewController(player: Player): UIViewController = ComposeUIViewController {
            val playerHistoryComposeState = playerHistoryState.collectAsState(emptyList())
            PlayerDetailsViewShared(player, playerHistoryComposeState.value)
        }

        fun updatePlayerHistory(playerHistory: List<PlayerPastHistory>) {
            playerHistoryState.value = playerHistory
        }
    }


Adding the chart

The following is the key composable for displaying the player history chart (more information about KoalaPlot and the other chart types it supports can be found in this page). I’ve omitted for clarity some of the composables for showing for example various titles (they’re mostly wrapping Text elements) but full code can be found in the FantasyPremierLeague repository.

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
49
50
51
@Composable
private fun PlayerHistoryChart(
    playerHistory: List<PlayerPastHistory>,
    tickPositionState: TickPositionState,
    title: String
) {
    val barChartEntries = remember(playerHistory) { mutableStateOf(barChartEntries(playerHistory)) }

    ChartLayout(title = { ChartTitle(title) }) {
        XYChart(
            xAxisModel = CategoryAxisModel(playerHistory.map { it.seasonName }),
            yAxisModel = LinearAxisModel(
                0f..playerHistory.maxOf { it.totalPoints }.toFloat(),
                minimumMajorTickIncrement = 1f,
                minorTickCount = 0
            ),
            xAxisStyle = rememberAxisStyle(
                tickPosition = tickPositionState.horizontalAxis,
                color = Color.LightGray
            ),
            xAxisLabels = {
                AxisLabel(it, Modifier.padding(top = 2.dp))
            },
            xAxisTitle = { AxisTitle("Season") },
            yAxisStyle = rememberAxisStyle(tickPosition = tickPositionState.verticalAxis),
            yAxisLabels = {
                AxisLabel(it.toString(1), Modifier.absolutePadding(right = 2.dp))
            },
            yAxisTitle = {
                AxisTitle(
                    "Points",
                    modifier = Modifier.rotateVertically(VerticalRotation.COUNTER_CLOCKWISE)
                        .padding(bottom = padding)
                )
            },
            verticalMajorGridLineStyle = null
        ) {
            VerticalBarChart(
                series = listOf(barChartEntries.value),
                bar = { _, _, value ->
                    DefaultVerticalBar(
                        brush = SolidColor(Color.Blue),
                        modifier = Modifier.fillMaxWidth(barWidth),
                    ) {
                        HoverSurface { Text(value.yMax.toString()) }
                    }
                }
            )
        }
    }
}

Lastly, this is the barChartEntries method invoked above that converts the player history data to a BarChartEntry list that’s used when rendering the chart.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private fun barChartEntries(playerHistory: List<PlayerPastHistory>): List<BarChartEntry<String, Float>> {
    val list = mutableListOf<BarChartEntry<String, Float>>()

    playerHistory.forEach { player ->
        list.add(
            DefaultBarChartEntry(
                xValue = player.seasonName,
                yMin = 0f,
                yMax = player.totalPoints.toFloat(),
            )
        )
    }
    return list
}

It’s important to note again that this same code is running on Android, iOS and Desktop and, in the case of iOS, the UI is being rendered within an existing SwiftUI screen.


Featured in Android Weekly Issue #587