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