Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #553: Add AndroidX ViewModel integration as separate module #598

Closed
wants to merge 16 commits into from
1 change: 1 addition & 0 deletions demos/appyx-navigation/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.Appyx">
<activity
android:name="com.bumble.appyx.navigation.MainActivity"
android:name="com.bumble.appyx.navigation.node.viewModel.ViewModelExampleActivity"
android:exported="true"
android:theme="@style/Theme.Appyx.Starting">
<intent-filter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should revert this


override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = _uiState

fun incrementCounter() {
_uiState.getAndUpdate { value ->
UiState(value.counter + 1)
}
}

companion object {
object StartCounterKey : CreationExtras.Key<Int>

val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val startCounterValue = this[StartCounterKey] ?: 0
ViewModelExample(
startCounterValue
)
}
}
}
}

data class UiState(val counter: Int)
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}

}
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions utils/viewmodel-android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 5 additions & 0 deletions utils/viewmodel-android/lint-baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 7.1.3" type="baseline" client="gradle" dependencies="false"
name="AGP (7.1.3)" variant="all" version="7.1.3">

</issues>
2 changes: 2 additions & 0 deletions utils/viewmodel-android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
appyxV2IntegrationPoint.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
appyxV2IntegrationPoint.onSaveInstanceState(outState)
}

}
Original file line number Diff line number Diff line change
@@ -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<String, ViewModelStore>()
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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading