I’ve written about the use of Compose Multiplatform for sharing UI code in a number of previous articles but was inspired by following recent session by Touchlab (and related sample) to explore this further….in particular in the PeopleInSpace Kotlin Multiplatform (KMP) sample and specifically for the UI used for showing the position of the International Space Station (ISS).
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
Overview
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.
- SwiftUI for the “outer shell” of the screen including the navigation and bottom bars
- Shared Compose Multiplatform UI code the content area
- Native components for the map (standard MapKit on iOS and osmdroid on Android)
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.
Implementation
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.
- Blue: outer SwiftUI shell (ISSPositionScreen.swift)
- Green: shared Compose Multiplatform UI code for the content area (ISSPositionContent.kt)
- Red: SwiftUI map component (NativeISSMapView.swift)
Outer SwiftUI shell
We’re using in this case a standard SwiftUI TabView
to display the navigation bar shown at the bottom of the screen.
ContentView.swift
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.
ISSPositionScreen.swift
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)
}
}
Shared Compose Multiplatform code
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.
iOSMain/SharedViewControllers.kt
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.
ISSPositionContent.kt
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.
iOSMain/ISSMapView.ios.kt
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)
}
)
}
SwiftUI map component
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.
NativeISSMapView.swift
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
Related tweet
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)