Site icon JoinAppStudio

Compose Multiplatform — managing UI State on iOS | by Guilherme Delgado | Aug, 2023

When you use Compose Multiplatform on iOS, the Kotlin code for your UI is compiled to native code using Kotlin/Native. This native code is then used to create a UIKit-based UI that runs on the iOS platform. Compose Multiplatform for iOS provides the Kotlin APIs for building Compose UIs on iOS. This library bridges the gap between the Kotlin/Native code and the UIKit framework.

Kotlin code
|
v
Kotlin/Native compiler
|
v
Native code for iOS
|
v
UIKit framework
|
v
Compose UI

The alpha² release of Compose Multiplatform introduces a prototype for bidirectional interaction at the UI level. With the help of UIKitView, you can seamlessly integrate intricate platform-specific widgets — such as maps, web views, media players, and camera feeds — into your shared user interface. Conversely, employing ComposeUIViewController allows you to nest Compose Multiplatform screens within SwiftUI applications, facilitating a gradual integration of Compose Multiplatform into your iOS apps.

Our emphasis will be on the latter.

By this, I’m referring to a screen implementation in which state changes are managed by the multiplatform (shared-ui) module, in Kotlin. This implies that on the iOS side, our task involves just creating a container to display it.

Let me simplify this with an example. We’ll create a Composable containing two buttons for navigating between screens. On iOS, we’ll include this Composable and set up our navigation.

In the shared-ui module, we create a function that gives us a UIViewController. This function wraps our Composable implementation within ComposeUIViewController:

fun selector(onClickA: () -> Unit, onClickB () -> Unit): UIViewController {
return ComposeUIViewController { /*compose implementation...*/ }
}

In iOS we create a UIViewControllerRepresentable that will import the ComposeUIViewController:

private struct SelectorUIViewController: UIViewControllerRepresentable {

let showScreenA: () -> Void
let showScreenB: () -> Void

func makeUIViewController(context: Context) -> UIViewController {
return SharedViewControllersKt.selector(onClickA: showScreenA, onClickB: showScreenB)
}

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

We finish it by setting up our navigation:

private enum Destination: Hashable {
case screenA
case screenB
}

struct HomeScreen: View {

@State var navigation = NavigationPath()

var body: some View {
NavigationStack(path: $navigation) {
SelectorUIViewController(
showScreenA: { navigation.append(Destination.screenA) },
showScreenB: { navigation.append(Destination.screenB) }
)
.navigationDestination(for: Destination.self) { destination in
switch destination {
case .screenA:
ScreenA()
case .screenB:
ScreenB()
}
}
.ignoresSafeArea()
}
}
}

And it’s done. We have a Composable inside a SwiftUI View.

By this, I’m referring to a screen implementation in which state changes are not managed by the shared-ui module. This implies that on the iOS side, we must forward state changes to the shared-ui module.

In this example, we have a SwiftUI View where a ViewModel handles the state, passing changes to State and Binding properties:

struct StatusScreen: View {

@StateObject private var viewModel = StatusViewModel()
@State private var status: String = ""

var body: some View {
StatusUIViewController(
status: $status,
action: { viewModel.changeStatus() }
)
.onReceive(viewModel.$state) { new in
status = new.status
}
.ignoresSafeArea()
}
}

Our UIViewControllerRepresentable:

struct StatusUIViewController: UIViewControllerRepresentable {

@Binding var status: String
let action: () -> Void

func makeUIViewController(context: Context) -> UIViewController {
return SharedViewControllers().statusComposable(status: status, click: action)
}

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
//how to update the composable state when binding values changes...?
}
}

Despite the invocation of updateUIViewController, the Multiplatform Compose API lacks³ a mechanism to communicate these changes to the Composable.

Thankfully, there’s a workaround we can employ. Within our ComposeUIViewController, we gather state changes using a MutableStateFlow. Subsequently, we expose a function for iOS to invoke, facilitating the emission of these changes:

object SharedViewControllers {

private data class ComposeUIViewState(val status: String = "")
private val state = MutableStateFlow(ComposeUIViewState())

fun statusComposable(click: () -> Unit): UIViewController {
return ComposeUIViewController {
with(state.collectAsState().value) {
StatusComposable(state.status, click)
}
}
}

fun updateStatusComposable(status: String) {
state.update { it.copy(status = status) }
}
}

And then our updateUIViewController becomes:

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
SharedViewControllers().updateStatusComposable(status: status)
}

While not the most elegant approach, it’s essential to keep in mind that we’re working with an alpha version. With time, we can anticipate significant enhancements and refinements.

Source link

Exit mobile version