diff --git a/demos/appyx-navigation/android/build.gradle.kts b/demos/appyx-navigation/android/build.gradle.kts index 69c5cf26f..0ea3dfcc8 100644 --- a/demos/appyx-navigation/android/build.gradle.kts +++ b/demos/appyx-navigation/android/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(project(":appyx-interactions:appyx-interactions")) implementation(project(":appyx-components:stable:backstack:backstack")) implementation(project(":appyx-components:experimental:cards:cards")) + implementation(project(":utils:viewmodel-android")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) diff --git a/demos/appyx-navigation/android/src/main/AndroidManifest.xml b/demos/appyx-navigation/android/src/main/AndroidManifest.xml index 690ed8641..24103bc43 100644 --- a/demos/appyx-navigation/android/src/main/AndroidManifest.xml +++ b/demos/appyx-navigation/android/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ android:supportsRtl="true" android:theme="@style/Theme.Appyx"> diff --git a/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/MainActivity.kt b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/MainActivity.kt index e2336ee70..efe9d3a17 100644 --- a/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/MainActivity.kt +++ b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/MainActivity.kt @@ -12,17 +12,17 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.ExperimentalUnitApi import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.bumble.appyx.navigation.integration.NodeActivity import com.bumble.appyx.navigation.integration.NodeHost import com.bumble.appyx.navigation.modality.BuildContext import com.bumble.appyx.navigation.node.container.ContainerNode import com.bumble.appyx.navigation.platform.AndroidLifecycle import com.bumble.appyx.navigation.ui.AppyxSampleAppTheme +import com.bumble.appyx.utils.viewmodel.integration.ViewModelNodeActivity @ExperimentalUnitApi @ExperimentalAnimationApi @ExperimentalComposeUiApi -class MainActivity : NodeActivity() { +class MainActivity : ViewModelNodeActivity() { override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() diff --git a/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExample.kt b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExample.kt new file mode 100644 index 000000000..35992842c --- /dev/null +++ b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExample.kt @@ -0,0 +1,36 @@ +package com.bumble.appyx.navigation.node.viewModel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate + +class ViewModelExample(startCounterValue: Int = 0) : ViewModel() { + private val _uiState = MutableStateFlow(UiState(startCounterValue)) + val uiState: StateFlow = _uiState + + fun incrementCounter() { + _uiState.getAndUpdate { value -> + UiState(value.counter + 1) + } + } + + companion object { + object StartCounterKey : CreationExtras.Key + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val startCounterValue = this[StartCounterKey] ?: 0 + ViewModelExample( + startCounterValue + ) + } + } + } +} + +data class UiState(val counter: Int) diff --git a/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExampleActivity.kt b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExampleActivity.kt new file mode 100644 index 000000000..96948e4b2 --- /dev/null +++ b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExampleActivity.kt @@ -0,0 +1,39 @@ +package com.bumble.appyx.navigation.node.viewModel + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.ExperimentalUnitApi +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.bumble.appyx.navigation.integration.NodeHost +import com.bumble.appyx.navigation.platform.AndroidLifecycle +import com.bumble.appyx.navigation.ui.AppyxSampleAppTheme +import com.bumble.appyx.utils.viewmodel.integration.ViewModelNodeActivity + +@ExperimentalUnitApi +@ExperimentalAnimationApi +@ExperimentalComposeUiApi +class ViewModelExampleActivity : ViewModelNodeActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + setContent { + AppyxSampleAppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + NodeHost( + lifecycle = AndroidLifecycle(LocalLifecycleOwner.current.lifecycle), + integrationPoint = appyxV2IntegrationPoint, + ) { + ViewModelExampleNode(it) + } + } + } + } + } + +} diff --git a/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExampleNode.kt b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExampleNode.kt new file mode 100644 index 000000000..5f29a5a70 --- /dev/null +++ b/demos/appyx-navigation/android/src/main/kotlin/com/bumble/appyx/navigation/node/viewModel/ViewModelExampleNode.kt @@ -0,0 +1,68 @@ +package com.bumble.appyx.navigation.node.viewModel + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.bumble.appyx.interactions.core.state.MutableSavedStateMap +import com.bumble.appyx.navigation.modality.BuildContext +import com.bumble.appyx.utils.viewmodel.node.ViewModelNode +import com.bumble.appyx.utils.viewmodel.node.viewModels + +class ViewModelExampleNode( + buildContext: BuildContext, + private val startCounterValue: Int = (buildContext.savedStateMap?.getValue("startCounterValue") as? Int) ?: 10 +) : ViewModelNode(buildContext) { + + private val viewModel: ViewModelExample by viewModels( + factoryProducer = { + viewModelFactory { + initializer { + ViewModelExample( + startCounterValue + ) + } + } + } + ) + + override fun onSaveInstanceState(state: MutableSavedStateMap) { + state["startCounterValue"] = viewModel.uiState.value.counter + super.onSaveInstanceState(state) + } + + + @Composable + @Override + override fun View(modifier: Modifier) { + val uiState by viewModel.uiState.collectAsState(initial = UiState(0)) + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = "Counter: ${uiState.counter}", + fontSize = 45.sp + ) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { viewModel.incrementCounter() } + ) { + Text("Increment") + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5d3fde20..e9c26d51b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,13 +26,17 @@ uuid = "9.0.0" [libraries] androidx-activity-compose = "androidx.activity:activity-compose:1.7.2" +androidx-activity = "androidx.activity:activity:1.7.2" androidx-appcompat = "androidx.appcompat:appcompat:1.3.1" androidx-arch-core-testing = "androidx.arch.core:core-testing:2.1.0" androidx-core = "androidx.core:core-ktx:1.9.0" +androidx-core-core = "androidx.core:core:1.9.0" androidx-core-splashscreen = "androidx.core:core-splashscreen:1.0.0" androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "androidx-lifecycle" } androidx-lifecycle-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version = "androidx-lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation-compose" } androidx-test-espresso-core = "androidx.test.espresso:espresso-core:3.5.1" androidx-test-junit = "androidx.test.ext:junit:1.1.3" diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a3272165..b4681542b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -82,6 +82,7 @@ include( ":utils:testing-ui-activity", ":utils:testing-unit-common", ":utils:multiplatform", + ":utils:viewmodel-android", ) // do not remove this. Otherwise all multiplatform modules will produce clashing artifacts diff --git a/utils/viewmodel-android/build.gradle.kts b/utils/viewmodel-android/build.gradle.kts new file mode 100644 index 000000000..c3246d841 --- /dev/null +++ b/utils/viewmodel-android/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("com.bumble.appyx.android.library") + id("appyx-publish-android") +} + +publishingPlugin { + artifactId = "utils-node-viewmodel" +} + +appyx { + namespace.set("com.bumble.appyx.utils.viewmodel") + + buildFeatures { + compose.set(true) + } +} + +dependencies { + api(project(":appyx-navigation:appyx-navigation")) + api(project(":appyx-interactions:appyx-interactions")) + api(libs.androidx.activity) + api(libs.androidx.appcompat) + api(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.compose) + debugApi(libs.compose.runtime) + implementation(libs.androidx.core.core) + implementation(libs.androidx.lifecycle.common) + releaseImplementation(libs.compose.runtime) +} diff --git a/utils/viewmodel-android/lint-baseline.xml b/utils/viewmodel-android/lint-baseline.xml new file mode 100644 index 000000000..ca8f55df7 --- /dev/null +++ b/utils/viewmodel-android/lint-baseline.xml @@ -0,0 +1,5 @@ + + + + diff --git a/utils/viewmodel-android/src/main/AndroidManifest.xml b/utils/viewmodel-android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/utils/viewmodel-android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ActivityIntegrationPointWithViewModelStoreProvider.kt b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ActivityIntegrationPointWithViewModelStoreProvider.kt new file mode 100644 index 000000000..12026678a --- /dev/null +++ b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ActivityIntegrationPointWithViewModelStoreProvider.kt @@ -0,0 +1,13 @@ +package com.bumble.appyx.utils.viewmodel.integration + +import android.os.Bundle +import androidx.activity.ComponentActivity +import com.bumble.appyx.navigation.integration.ActivityIntegrationPoint + +open class ActivityIntegrationPointWithViewModelStoreProvider( + activity: ComponentActivity, + savedInstanceState: Bundle?, +) : ActivityIntegrationPoint(activity, savedInstanceState) { + + open val viewModelStoreProvider = ViewModelStoreProvider.getInstance(activity) +} diff --git a/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ViewModelNodeActivity.kt b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ViewModelNodeActivity.kt new file mode 100644 index 000000000..17940d561 --- /dev/null +++ b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ViewModelNodeActivity.kt @@ -0,0 +1,56 @@ +package com.bumble.appyx.utils.viewmodel.integration + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.bumble.appyx.navigation.integrationpoint.IntegrationPointProvider + +/** + * Helper class for root [Node] integration into projects using [AppCompatActivity]. + * + * See [NodeComponentActivity] for building upon [ComponentActivity]. + * + * Also offers base functionality to satisfy dependencies of Android-related functionality + * down the tree via [appyxV2IntegrationPoint]: + * - [ActivityStarter] + * - [PermissionRequester] + * + * Feel free to not extend this and use your own integration point - in this case, + * don't forget to take a look here what methods needs to be forwarded to the root Node. + */ +open class ViewModelNodeActivity : AppCompatActivity(), IntegrationPointProvider { + + override lateinit var appyxV2IntegrationPoint: ActivityIntegrationPointWithViewModelStoreProvider + protected set + + protected open fun createIntegrationPoint(savedInstanceState: Bundle?) = + ActivityIntegrationPointWithViewModelStoreProvider( + activity = this, + savedInstanceState = savedInstanceState + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + appyxV2IntegrationPoint = createIntegrationPoint(savedInstanceState) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + appyxV2IntegrationPoint.onActivityResult(requestCode, resultCode, data) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + appyxV2IntegrationPoint.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + appyxV2IntegrationPoint.onSaveInstanceState(outState) + } + +} diff --git a/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ViewModelStoreProvider.kt b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ViewModelStoreProvider.kt new file mode 100644 index 000000000..cc06e9692 --- /dev/null +++ b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/integration/ViewModelStoreProvider.kt @@ -0,0 +1,30 @@ +package com.bumble.appyx.utils.viewmodel.integration + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.get + +open class ViewModelStoreProvider : ViewModel() { + private val viewModelStores = mutableMapOf() + fun clear(nodeId: String) { + val viewModelStore = viewModelStores.remove(nodeId) + viewModelStore?.clear() + } + + override fun onCleared() { + super.onCleared() + viewModelStores.values.forEach { it.clear() } + viewModelStores.clear() + } + + fun getViewModelStoreForNode(nodeId: String): ViewModelStore = + viewModelStores.getOrPut(nodeId) { ViewModelStore() } + + companion object { + fun getInstance(viewModelStoreOwner: ViewModelStoreOwner): ViewModelStoreProvider { + return ViewModelProvider(viewModelStoreOwner).get() + } + } +} diff --git a/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelNode.kt b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelNode.kt new file mode 100644 index 000000000..df19e50b9 --- /dev/null +++ b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelNode.kt @@ -0,0 +1,32 @@ +package com.bumble.appyx.utils.viewmodel.node + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.bumble.appyx.navigation.lifecycle.DefaultPlatformLifecycleObserver +import com.bumble.appyx.navigation.modality.BuildContext +import com.bumble.appyx.navigation.node.Node +import com.bumble.appyx.utils.viewmodel.integration.ActivityIntegrationPointWithViewModelStoreProvider + +open class ViewModelNode( + buildContext: BuildContext, +) : Node(buildContext), ViewModelStoreOwner { + + private val nodeViewModelStore by lazy { + (integrationPoint as ActivityIntegrationPointWithViewModelStoreProvider).viewModelStoreProvider.getViewModelStoreForNode( + id + ) + } + + init { + lifecycle.addObserver(object : DefaultPlatformLifecycleObserver { + override fun onDestroy() { + if (!(integrationPoint as ActivityIntegrationPointWithViewModelStoreProvider).isChangingConfigurations) { + (integrationPoint as ActivityIntegrationPointWithViewModelStoreProvider).viewModelStoreProvider.clear(id) + } + } + }) + } + + override val viewModelStore: ViewModelStore + get() = nodeViewModelStore +} diff --git a/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelParentNode.kt b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelParentNode.kt new file mode 100644 index 000000000..02b90dcda --- /dev/null +++ b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelParentNode.kt @@ -0,0 +1,37 @@ +package com.bumble.appyx.utils.viewmodel.node + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.bumble.appyx.interactions.core.model.AppyxComponent +import com.bumble.appyx.navigation.lifecycle.DefaultPlatformLifecycleObserver +import com.bumble.appyx.navigation.modality.BuildContext +import com.bumble.appyx.navigation.node.ParentNode +import com.bumble.appyx.utils.viewmodel.integration.ActivityIntegrationPointWithViewModelStoreProvider + +abstract class ViewModelParentNode( + buildContext: BuildContext, + node: AppyxComponent, +) : ParentNode( + buildContext = buildContext, + appyxComponent = node +), ViewModelStoreOwner { + + private val nodeViewModelStore by lazy { + (integrationPoint as ActivityIntegrationPointWithViewModelStoreProvider).viewModelStoreProvider.getViewModelStoreForNode( + id + ) + } + + init { + lifecycle.addObserver(object : DefaultPlatformLifecycleObserver { + override fun onDestroy() { + if (!(integrationPoint as ActivityIntegrationPointWithViewModelStoreProvider).isChangingConfigurations) { + (integrationPoint as ActivityIntegrationPointWithViewModelStoreProvider).viewModelStoreProvider.clear(id) + } + } + }) + } + + override val viewModelStore: ViewModelStore + get() = nodeViewModelStore +} diff --git a/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelStoreOwnerExt.kt b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelStoreOwnerExt.kt new file mode 100644 index 000000000..86a28c4f1 --- /dev/null +++ b/utils/viewmodel-android/src/main/kotlin/com/bumble/appyx/utils/viewmodel/node/ViewModelStoreOwnerExt.kt @@ -0,0 +1,31 @@ +package com.bumble.appyx.utils.viewmodel.node + +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelLazy +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.get +import androidx.lifecycle.viewmodel.CreationExtras + +@MainThread +inline fun ViewModelStoreOwner.viewModels( + noinline extrasProducer: (() -> CreationExtras)? = null, + noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null +): Lazy { + val factoryPromise = factoryProducer ?: { + ViewModelProvider.NewInstanceFactory.instance + } + + return ViewModelLazy( + VM::class, + { viewModelStore }, + factoryPromise, + { extrasProducer?.invoke() ?: CreationExtras.Empty } + ) +} + +@MainThread +inline fun ViewModelStoreOwner.viewModel(factory: ViewModelProvider.Factory): VM { + return ViewModelProvider(this, factory).get() +}