I’ve written about the use of Compose Multiplatform (CMP) 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).

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.

ISS screenshot on Android and iOS


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)

ISS screenshot iOS

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)
                }
            }
        }
    }
}