uiviewcontrollerandroid-jetpack-composekotlin-multiplatform

How to communicate from an iOS ViewController to a Kotlin Composable


I'm playing with KMM and I'm trying to achieve a solution where my Jetpack Composables are used only 100% just for the UI.

I mean that I want to use KMM strictly to only create UI, I don't want to share any other code. I don't want to share Kotlin ViewModels or anything else. I'd like Android to use its own ViewModel system and iOS its.

So, for example, I'm implementing a simple Countdown app. I have two ViewModels, one for iOS and one for Android:

Android

class CountdownViewModel : ViewModel() {
    private val totalTimeSeconds = 15 * 60 // 15 minutes in seconds
    private var remainingTime = totalTimeSeconds

    private val _time = MutableStateFlow(formatTime(remainingTime))
    val time: StateFlow<String> = _time.asStateFlow()

    private val _progress = MutableStateFlow(1f)
    val progress: StateFlow<Float> = _progress.asStateFlow()

    private val _isRunning = MutableStateFlow(false)
    val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()

    fun startTimer() {
        if (isRunning.value) return
        _isRunning.value = true
        viewModelScope.launch {
            while (remainingTime > 0 && isRunning.value) {
                delay(1000)
                remainingTime--
                _time.value = formatTime(remainingTime)
                _progress.value = remainingTime / totalTimeSeconds.toFloat()
            }
            _isRunning.value = false
        }
    }
    
    fun toggleTimer() {
        if (isRunning.value) stopTimer() else startTimer()
    }

    fun stopTimer() {
        _isRunning.value = false
        remainingTime = totalTimeSeconds
        _time.value = formatTime(remainingTime)
        _progress.value = 1f
    }

    private fun formatTime(seconds: Int): String {
        val minutes = seconds / 60
        val secs = seconds % 60
        return "%02d:%02d".format(minutes, secs)
    }
}

iOS

class CountdownViewModel: ObservableObject {
    private let totalTimeSeconds = 10 * 60 // 10 minutes in seconds
    private var remainingTime: Int
    private var timer: Timer?

    u/Published var time: String
    @Published var progress: Float
    @Published var isRunning: Bool

    init() {
        self.remainingTime = totalTimeSeconds
        self.time = CountdownViewModel.formatTime(totalTimeSeconds)
        self.progress = 1.0
        self.isRunning = false
    }

    func startTimer() {
        if isRunning { return }
        isRunning = true
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            if self.remainingTime > 0 {
                self.remainingTime -= 1
                self.time = CountdownViewModel.formatTime(self.remainingTime)
                self.progress = Float(self.remainingTime) / Float(self.totalTimeSeconds)
            } else {
                self.stopTimer()
            }
        }
    }

    func toggleTimer() {
        if isRunning {
            stopTimer()
        } else {
            startTimer()
        }
    }

    func stopTimer() {
        isRunning = false
        timer?.invalidate()
        timer = nil
        remainingTime = totalTimeSeconds
        time = CountdownViewModel.formatTime(remainingTime)
        progress = 1.0
    }

    private static func formatTime(_ seconds: Int) -> String {
        let minutes = seconds / 60
        let secs = seconds % 60
        return String(format: "%02d:%02d", minutes, secs)
    }
}

The Android UI

class MainActivity : ComponentActivity() {
    private val viewModel by viewModels<CountdownViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val progress by viewModel.progress.collectAsStateWithLifecycle(initialValue = 0f)
            val time by viewModel.time.collectAsStateWithLifecycle()
            val isRunning by viewModel.isRunning.collectAsStateWithLifecycle()
        
            App(
                onButtonClicked = {
                    viewModel.toggleTimer()
                },
                progress = progress,
                time = time,
                isRunning = isRunning
            )
        }
    }
}

The iOS UI

MainViewController.kt

fun FullMainViewController(
    time: String,
    progress: Float,
    isRunning: Boolean,
    toggleTimer: () -> Unit
) = ComposeUIViewController {
    App(
        onButtonClicked = toggleTimer,
        progress = progress,
        time = time,
        isRunning = isRunning
    )
}

ContentView.swift

struct ComposeView: UIViewControllerRepresentable {
    @ObservedObject var viewModel: CountdownViewModel

    func makeUIViewController(context: Context) -> UIViewController {
        return MainViewControllerKt.FullMainViewController(
            time: viewModel.time,
            progress: viewModel.progress,
            isRunning: viewModel.isRunning,
            toggleTimer: { viewModel.toggleTimer() }
        )
    }

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

struct ContentView: View {
    @StateObject private var viewModel = CountdownViewModel()
    
    var body: some View {
        ComposeView(
            viewModel: viewModel
        )
        .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
    }
}

The problem

The Android app works perfectly, but I cannot figure out a way to have the composable be updated on iOS. I mean, I could add an .id(viewModel.time) to the ComposeView so the makeUIViewController gets called every time, but the performance looks terrible. Is there any other way to be able to update the composable from iOS?

Notes

I know some of you might suggest to just share the same ViewModel through Kotlin, but I want to avoid that. I'm looking at creating a solution that addresses only UI, I'd like to be able to import this as a UI library into Android and iOS.

Edit Mar 19th

I have found a library that so far let's me achieve what I wanted:

https://github.com/GuilhE/KMP-ComposeUIViewController

For example, I have this shared Composable:

@Composable
fun App(
    onButtonClicked: (() -> Unit) = {},
    progress: Float = 0f,
    time: String = "12:34",
    isRunning: Boolean = false
) {
    FastHeroTheme {
        Scaffold {
            Column(
                modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "Timer",
                    textAlign = TextAlign.Center,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(20.dp),
                    style = MaterialTheme.typography.bodyLarge
                )

                PulseEffect(
                    pulseFraction = if (isRunning) 0.95f else 1f
                ) {
                    Timer(
                        modifier = Modifier.fillMaxSize(),
                        progress = progress,
                        elapsed = time,
                        isCountingDown = isRunning,
                        isRestarting = false,
                        isSetting = false,
                    )
                }

                Button(
                    modifier = Modifier
                        .padding(top = 70.dp)
                        .wrapContentSize(),
                    onClick = onButtonClicked,
                ) {
                    Text(
                        text = if (isRunning) "Stop" else "Start",
                        fontSize = 14.sp,
                        textAlign = TextAlign.Center,
                        style = MaterialTheme.typography.labelLarge
                    )
                }
            }
        }
    }
}

This UI is pretty much just a stop-watch UI. I was playing around with implementing some sort of Fasting app.

Then in iosMain I have this MainViewController.kt file:

data class MainViewState(
    val time: String,
    val progress: Float,
    val isRunning: Boolean,
)

@ComposeUIViewController
@Composable
fun MainViewScreen(
    @ComposeUIViewControllerState state: MainViewState,
    toggleTimer: () -> Unit,
) {
    App(
        onButtonClicked = toggleTimer,
        progress = state.progress,
        time = state.time,
        isRunning = state.isRunning
    )
}

Then in the actual iOS app I have this entry point:

@main
struct iOSApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

The ContentView uses the composable generated out of that:

import UIKit
import SwiftUI
import ComposeApp

struct ContentView: View {
    @StateObject private var viewModel = CountdownViewModel()
    @State private var state = MainViewState(time: "10:00", progress: 1.0, isRunning: false)

    var body: some View {
        MainViewScreenRepresentable(
            state: $state,
            toggleTimer: { viewModel.toggleTimer() }
        )
        .onReceive(viewModel.$time) { time in
            updateState(time: time)
        }
        .onReceive(viewModel.$progress) { progress in
            updateState(progress: progress)
        }
        .onReceive(viewModel.$isRunning) { isRunning in
            updateState(isRunning: isRunning)
        }
        .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
    }

    private func updateState(time: String? = nil, progress: Float? = nil, isRunning: Bool? = nil) {
            state = MainViewState(
                time: time ?? state.time,
                progress: progress ?? state.progress,
                isRunning: isRunning ?? state.isRunning
            )
        }
}

#Preview {
    ContentView()
}

And in the end I can use a fully native iOS ViewModel:

import Foundation
import Combine
import SwiftUI
import ComposeApp

class CountdownViewModel: ObservableObject {
    private let totalTimeSeconds = 10 * 60 // 10 minutes in seconds
    private var remainingTime: Int
    private var timer: Timer?

    @Published var time: String
    @Published var progress: Float
    @Published var isRunning: Bool

    init() {
        self.remainingTime = totalTimeSeconds
        self.time = CountdownViewModel.formatTime(totalTimeSeconds)
        self.progress = 1.0
        self.isRunning = false
    }

    func startTimer() {
        if isRunning { return }
        isRunning = true
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            if self.remainingTime > 0 {
                self.remainingTime -= 1
                self.time = CountdownViewModel.formatTime(self.remainingTime)
                self.progress = Float(self.remainingTime) / Float(self.totalTimeSeconds)
            } else {
                self.stopTimer()
            }
        }
    }

    func toggleTimer() {
        if isRunning {
            stopTimer()
        } else {
            startTimer()
        }
    }

    func stopTimer() {
        isRunning = false
        timer?.invalidate()
        timer = nil
        remainingTime = totalTimeSeconds
        time = CountdownViewModel.formatTime(remainingTime)
        progress = 1.0
    }

    private static func formatTime(_ seconds: Int) -> String {
        let minutes = seconds / 60
        let secs = seconds % 60
        return String(format: "%02d:%02d", minutes, secs)
    }
}


Solution

  • Compose is not designed to be used this way, so there is no nice and clean solution.

    I think ComposeUIViewController could have had a setContent method that could have been called inside updateUIViewController, if you think your use case can be useful for multiple people you can try to describe it in detail and open a feature request on youtrack.


    But there's a workaround you can use right now. The only thing besides manually triggering an update with setContent that could update composable is compose state. You can also use it on the iOS side.

    You would need to make mutableStateOf available from iOS. Create a file under iosMain and put this function in it:

    fun <T> mutableStateOf(
        value: T,
    ) = androidx.compose.runtime.mutableStateOf(value)
    

    Then you can create an annotation wrapper on the iOS side:

    @propertyWrapper
    struct ComposeState<Value>: DynamicProperty {
        let state: RuntimeMutableState
    
        init(_ wrappedValue: Value) { }
            // Platform_iosKt name is generated from the kt filename where the extension is placed
            state = Platform_iosKt.mutableStateOf(value: wrappedValue)
        }
    
        var wrappedValue: Value {
            get { state.value as! Value }
            nonmutating set { state.setValue(newValue) }
        }
    }
    

    Note that this is quite unsafe, since ObjC doesn't support having a generic type for an interface. So the value type should only be a primitive type or a class created from Kotlin code.

    On your Kotlin side, you would have to accept state for each value that could change:

    fun FullMainViewController(
        time: State<String>,
        progress: State<Float>,
        isRunning: State<Boolean>,
        toggleTimer: () -> Unit
    )
    

    And create it like this in your view model. Note that I've moved makeController inside view model so you can access property wrapper private state.

    @ComposeState var time: String
    @ComposeState var progress: Float
    @ComposeState var isRunning: Bool
    
    func makeController() -> UIViewController {
        Main_iosKt.MainViewController(
            time: _time.state,
            progress: _progress.state,
            isRunning: _isRunning.state
        )
    }
    

    You could create a generic class that wraps the state on the Kotlin side, it would be much more type safe, especially if you create some container on the Kotlin side and pass it inside the state. But in that case you would have to write a lot of boilerplate to convert primitive structs to classes, like String to NSString.