rickclephas / KMP-ObservableViewModel

Library to use AndroidX/Kotlin ViewModels with SwiftUI
MIT License
569 stars 29 forks source link

Question: Plans on providing SavedStateHandle and Parcelize integration? #52

Open lmiskovic opened 10 months ago

lmiskovic commented 10 months ago

First of all, many thanks for your efforts in creating KMM-ViewModel :)

Are there any plans on providing integration of any kind with SavedStateHandle and Parcelize? In our project we have our own custom implementation of shared navigation logic that is integrated in our KMMViewModels. Having SavedStateHandle + Parcelize would make us able to use it to pass arguments/results while navigating

Edit: though it would probably require integration with something like navController so I'm not sure if SavedStateHandle in KMMViewModel would help us at all. But could come in handy in some other cases also.

rickclephas commented 10 months ago

No concrete plans to support SavedStateHandle and/or Parcelize, however those are indeed very nice additions. Feel free to share more information about your specific use case, that always helps to understand what's needed/missing.

kit0kat commented 2 weeks ago

Hi,

I really like how simple your multiplatform ViewModel works and would like to use your lib for my next KMP project :)

Unfortunately, for my project it's necessary to pass arguments to some screens via ViewModel, e.g:

ListScreen -> (user selects an item) -> DetailsScreen(id)

I saw that you are already working on this, do you have an estimated release date for this feature?

rickclephas commented 2 weeks ago

I really like how simple your multiplatform ViewModel works and would like to use your lib for my next KMP project :)

Thanks!

Unfortunately, for my project it's necessary to pass arguments to some screens via ViewModel, e.g:

ListScreen -> (user selects an item) -> DetailsScreen(id)

Could you describe more about your use case? What exactly are you missing? Passing something like an ID to the constructor is already possible.

I saw that you are already working on this, do you have an estimated release date for this feature?

Not really an ETA for the SavedStateHandle support I am afraid.

kit0kat commented 1 week ago

Could you describe more about your use case?

I'd like to find a multiplatform solution for ViewModels while still being able to use the SavedStateHandle on Android to survive system-initiated process death. I also want to be able to pass some parameters, like an ID to a ViewModel (e.g. the user navigates from a list of items to a specific subscreen)

Passing something like an ID to the constructor is already possible.

Yes, that's true. But things get tricky when you use dependency injection frameworks, because the constructor arguments of the ViewModel are typically used for dependencies, not parameters. For example, on Android, when you use the Navigation Compose library, parameters such as IDs are passed to the ViewModel via the SavedStateHandle.

MY SOLUTION:

I found a generic solution to my use case using your library. I want to share my solution because I think it might help you think about how (or if) to integrate SavedStateHandle into your library.

My shared expected ViewModel:

import com.rickclephas.kmp.observableviewmodel.ViewModel as KMPViewModel
import kotlinx.coroutines.flow.MutableStateFlow

@OptIn(ExperimentalMultiplatform::class)
@OptionalExpectation
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
expect annotation class Parcelize()

@OptIn(ExperimentalMultiplatform::class)
@OptionalExpectation
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
expect annotation class Ignore()

expect interface Parcelable

expect class SavedStateHandle()

abstract class ViewModel<T : Parcelable>(
    savedStateHandle: SavedStateHandle,
    initialValue: T,
) : KMPViewModel() {
    val state: MutableStateFlow<T> = stateFlow(savedStateHandle, initialValue)
}

expect fun <T> ViewModel<*>.stateFlow(
    savedStateHandle: SavedStateHandle,
    initialValue: T,
): MutableStateFlow<T>

Actual Android implementation with real SavedStateHandle:

import androidx.lifecycle.Observer
import com.rickclephas.kmp.observableviewmodel.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext

actual typealias SavedStateHandle = androidx.lifecycle.SavedStateHandle
actual typealias Parcelize = kotlinx.android.parcel.Parcelize
actual typealias Parcelable = android.os.Parcelable
actual typealias Ignore = kotlinx.android.parcel.IgnoredOnParcel

actual fun <T> ViewModel<*>.stateFlow(
    savedStateHandle: SavedStateHandle,
    initialValue: T,
): MutableStateFlow<T> {
    val liveData = savedStateHandle.getLiveData("__state", initialValue)
    val stateFlow = MutableStateFlow(initialValue)
    val observer = Observer<T> { value ->
        if (value != stateFlow.value) {
            stateFlow.value = value
        }
    }
    liveData.observeForever(observer)
    stateFlow
        .onCompletion {
            withContext(Dispatchers.Main.immediate) {
                liveData.removeObserver(observer)
            }
        }
        .onEach { value ->
            withContext(Dispatchers.Main.immediate) {
                if (liveData.value != value) {
                    liveData.value = value
                }
            }
        }.launchIn(viewModelScope.coroutineScope)
    return stateFlow
}

Actual iOS implementation with a 'fake' SavedStateHandle:

import com.rickclephas.kmp.observableviewmodel.MutableStateFlow

actual class SavedStateHandle actual constructor()
actual interface Parcelable

actual fun <T> ViewModel<*>.stateFlow(
    savedStateHandle: SavedStateHandle,
    initialValue: T,
) = MutableStateFlow(viewModelScope, initialValue)

And here is an example ViewModel. CounterState is the ViewModel's state. The state class needs to be parcelable to save it to the SavedStateHandle. CounterRoute is a parameter which you can pass from outside. It needs to be serializable because I use it in the Android navigation graph (see example below).

import kotlinx.serialization.Serializable

@Serializable
data class CounterRoute(
    val title: String = "Counter",
    val initialCount: Int = 0,
)

@Parcelize
data class CounterState(
    val title: String = "",
    val count: Int = 0,
) : Parcelable

class CounterViewModel(
    savedStateHandle: SavedStateHandle,
    counterRoute: CounterRoute,
) : ViewModel<CounterState>(
    savedStateHandle,
    CounterState(
        title = counterRoute.title,
        count = counterRoute.initialCount,
    ),
) {

    fun increment() {
        state.value = state.value.copy(count = state.value.count + 1)
    }

}

Now, dependency injection is a bit tricky. I use the Kodein framework as follows:

import org.kodein.di.DI
import org.kodein.di.LazyDelegate
import org.kodein.di.bindProvider
import org.kodein.di.instance

typealias ViewModelCreator<R, VM> = (SavedStateHandle, R) -> VM
typealias CounterViewModelCreator = ViewModelCreator<CounterRoute, CounterViewModel>

object DependencyInjector {

    val di = DI.lazy {

        // TODO Add dependencies

        val counterViewModelCreator: CounterViewModelCreator = { savedStateHandle, counterRoute ->
            CounterViewModel(savedStateHandle, counterRoute) // Add other dependencies ...
        }

        bindProvider { counterViewModelCreator }

    }

    /* ------------------------------ ViewModels for Android ------------------------------ */
    inline fun <reified T : ViewModelCreator<*, *>> viewModelCreator(): LazyDelegate<T> {
        return di.instance()
    }

    /* ----------------------------- ViewModels for iOS & Web ----------------------------- */
    private val unused = SavedStateHandle()

    fun createCounterViewModel(route: CounterRoute): CounterViewModel {
        val creator: CounterViewModelCreator by di.instance()
        return creator(unused, route)
    }

}

Now I need a custom ViewModelFactory on Android (I implemented it directly in MainActivity):

private inline fun <reified VM : ViewModel, reified R : Any> NavBackStackEntry.getViewModel(): VM {
    val creator: ViewModelCreator<R, VM> by DependencyInjector.viewModelCreator()
    val viewModelFactory = GenericViewModelFactory { creator(savedStateHandle, toRoute()) }
    return ViewModelProvider(viewModelStore, viewModelFactory)[VM::class.java]
}

private class GenericViewModelFactory<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}

And here is how I use it on Android:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            NavHost(
                navController = navController,
                startDestination = CounterRoute("My Counter", 10)
            ) {
                composable<CounterRoute> {
                    val viewModel = it.getViewModel<CounterViewModel, CounterRoute>()
                    CounterScreen(viewModel)
                }
            }
        }
    }

And on iOS:

@StateViewModel var viewModel = DependencyInjector.shared.createCounterViewModel(route: CounterRoute(title: "My Counter", initialCount: 32))

This is just a proof of concept, but I will soon be starting a large-scale real-world project based on this approach.

Hope this helps you or anyone else using your awesome library :)