From a764cb9e2543a0e5aa3d30a963d1730f155be107 Mon Sep 17 00:00:00 2001 From: OISHI Masakuni Date: Mon, 7 Mar 2022 14:36:00 +0900 Subject: [PATCH 1/2] Add Jetpack Compose libraries. --- gradle/libs.versions.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c60a747..7f9005a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,8 @@ kotlin = "1.6.10" kotlinCoroutines = "1.6.0" kotlinMetadataJvm = "0.4.2" ksp = "1.6.10-1.0.4" +compose = "1.1.1" +androidxActivity = "1.4.0" androidxAppcompat = "1.4.1" androidxConstraintlayout = "2.1.3" androidxFragment = "1.4.1" @@ -54,6 +56,16 @@ kotlin-metadata-jvm = { group = "org.jetbrains.kotlinx", name = "kotlinx-metadat # ksp ksp-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" } +# compose +compose-material = { group = "androidx.compose.material", name = "material", version.ref = "compose" } +compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" } + +# androidxActivity +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } + # androidxAppcompat androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } @@ -70,11 +82,13 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-viewmodel-savedstate = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } # androidxNavigation androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "androidxNavigation" } androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "androidxNavigation" } androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "androidxNavigation" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } # androidxRoom androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" } From be4c71371fcaec49b79b74fb84bffb2b0aff0362 Mon Sep 17 00:00:00 2001 From: OISHI Masakuni Date: Mon, 7 Mar 2022 14:36:07 +0900 Subject: [PATCH 2/2] Add `viewmodel-compose` module that provides a utility function for Jetpack Compose. --- settings.gradle | 1 + viewmodel-compose/.gitignore | 1 + viewmodel-compose/build.gradle | 42 ++++ .../src/androidTest/AndroidManifest.xml | 8 + .../viewmodel/compose/LichViewModelTest.kt | 204 ++++++++++++++++++ .../src/main/AndroidManifest.xml | 2 + .../lich/viewmodel/compose/LichViewModel.kt | 79 +++++++ 7 files changed, 337 insertions(+) create mode 100644 viewmodel-compose/.gitignore create mode 100644 viewmodel-compose/build.gradle create mode 100644 viewmodel-compose/src/androidTest/AndroidManifest.xml create mode 100644 viewmodel-compose/src/androidTest/java/com/linecorp/lich/viewmodel/compose/LichViewModelTest.kt create mode 100644 viewmodel-compose/src/main/AndroidManifest.xml create mode 100644 viewmodel-compose/src/main/java/com/linecorp/lich/viewmodel/compose/LichViewModel.kt diff --git a/settings.gradle b/settings.gradle index 1839189..6796c6b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include ':savedstate' include ':savedstate-compiler' include ':viewmodel' include ':viewmodel-test' +include ':viewmodel-compose' include ':viewmodel-test-mockitokotlin' include ':viewmodel-test-mockk' include ':lifecycle' diff --git a/viewmodel-compose/.gitignore b/viewmodel-compose/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/viewmodel-compose/.gitignore @@ -0,0 +1 @@ +/build diff --git a/viewmodel-compose/build.gradle b/viewmodel-compose/build.gradle new file mode 100644 index 0000000..e2e4c8a --- /dev/null +++ b/viewmodel-compose/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'lich-library-android' +} + +lichLibrary { + version = VERSION_NAME_VIEWMODEL + name = 'Lich ViewModel (for Jetpack Compose)' + description = 'Lightweight framework for managing ViewModels.' + url = 'https://github.com/line/lich/tree/master/viewmodel' +} + +android { + compileSdk libs.versions.compileSdk.get().toInteger() + + defaultConfig { + minSdk 21 + targetSdk libs.versions.targetSdk.get().toInteger() + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.compose.get() + } +} + +dependencies { + api project(':viewmodel') + implementation libs.compose.ui + implementation libs.androidx.lifecycle.viewmodel.compose + + androidTestImplementation project(':savedstate') + androidTestImplementation libs.bundles.test.instrumentation + androidTestImplementation libs.compose.material + androidTestImplementation libs.compose.ui.test.junit4 + androidTestImplementation libs.androidx.navigation.compose +} diff --git a/viewmodel-compose/src/androidTest/AndroidManifest.xml b/viewmodel-compose/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..4ffceb1 --- /dev/null +++ b/viewmodel-compose/src/androidTest/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/viewmodel-compose/src/androidTest/java/com/linecorp/lich/viewmodel/compose/LichViewModelTest.kt b/viewmodel-compose/src/androidTest/java/com/linecorp/lich/viewmodel/compose/LichViewModelTest.kt new file mode 100644 index 0000000..b17aae4 --- /dev/null +++ b/viewmodel-compose/src/androidTest/java/com/linecorp/lich/viewmodel/compose/LichViewModelTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2022 LINE Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.linecorp.lich.viewmodel.compose + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.linecorp.lich.savedstate.initial +import com.linecorp.lich.viewmodel.AbstractViewModel +import com.linecorp.lich.viewmodel.ViewModelFactory +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class LichViewModelTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun differentViewModelStoreOwner() { + var firstViewModel: SimpleViewModel? = null + var secondViewModel: SimpleViewModel? = null + composeTestRule.setContent { + val navController = rememberNavController() + NavHost(navController, startDestination = "One") { + composable("One") { + // firstViewModel is owned by the current NavBackStackEntry. + firstViewModel = lichViewModel(SimpleViewModel) + } + } + // secondViewModel is owned by TestActivity. + secondViewModel = lichViewModel(SimpleViewModel) + } + composeTestRule.waitForIdle() + + assertNotNull(firstViewModel) { + assertEquals("", it.param) + assertFalse(it.isCleared) + } + assertNotNull(secondViewModel) { + assertEquals("", it.param) + assertFalse(it.isCleared) + } + assertNotSame(firstViewModel, secondViewModel) + } + + @Test + fun navigateWithParamThenPopBackStack() { + var firstViewModel: SimpleViewModel? = null + var secondViewModel: SimpleViewModel? = null + composeTestRule.setContent { + val navController = rememberNavController() + NavHost(navController, startDestination = "One") { + composable("One") { + firstViewModel = lichViewModel(SimpleViewModel) + NavigateButton("Two") { navController.navigate("Two/foo") } + } + composable("Two/{param}") { + secondViewModel = lichViewModel(SimpleViewModel) + NavigateButton("One") { navController.popBackStack() } + } + } + } + composeTestRule.waitForIdle() + + assertNotNull(firstViewModel) { + assertEquals("", it.param) + assertFalse(it.isCleared) + } + assertNull(secondViewModel) + + composeTestRule.onNodeWithText("Navigate to Two").performClick() + composeTestRule.waitForIdle() + + assertNotNull(firstViewModel) { + assertFalse(it.isCleared) + } + assertNotNull(secondViewModel) { + assertEquals("foo", it.param) + assertFalse(it.isCleared) + } + assertNotSame(firstViewModel, secondViewModel) + + composeTestRule.onNodeWithText("Navigate to One").performClick() + composeTestRule.waitForIdle() + + assertNotNull(firstViewModel) { + assertFalse(it.isCleared) + } + assertNotNull(secondViewModel) { + assertTrue(it.isCleared) + } + } + + @Test + fun sameParentViewModelAcrossRoutes() { + var firstViewModel: SimpleViewModel? = null + var secondViewModel: SimpleViewModel? = null + composeTestRule.setContent { + val navController = rememberNavController() + NavHost(navController, startDestination = "Main") { + navigation(startDestination = "One", route = "Main") { + composable("One") { + firstViewModel = lichViewModel( + SimpleViewModel, + navController.getBackStackEntry("Main") + ) + NavigateButton("Two") { navController.navigate("Two/foo") } + } + composable("Two/{param}") { + secondViewModel = lichViewModel( + SimpleViewModel, + navController.getBackStackEntry("Main") + ) + NavigateButton("One") { navController.popBackStack() } + } + } + } + } + composeTestRule.waitForIdle() + + assertNotNull(firstViewModel) { + assertEquals("", it.param) + assertFalse(it.isCleared) + } + assertNull(secondViewModel) + + composeTestRule.onNodeWithText("Navigate to Two").performClick() + composeTestRule.waitForIdle() + + assertNotNull(firstViewModel) + assertNotNull(secondViewModel) + assertSame(firstViewModel, secondViewModel) + + composeTestRule.onNodeWithText("Navigate to One").performClick() + composeTestRule.waitForIdle() + + assertNotNull(firstViewModel) { + assertFalse(it.isCleared) + } + assertSame(firstViewModel, secondViewModel) + } + + @Suppress("TestFunctionName") + @Composable + private fun NavigateButton(text: String, onClick: () -> Unit = {}) { + Button(onClick = onClick) { + Text(text = "Navigate to $text") + } + } + + class TestActivity : ComponentActivity() + + class SimpleViewModel(savedStateHandle: SavedStateHandle) : AbstractViewModel() { + + val param: String by savedStateHandle.initial("") + + var isCleared: Boolean = false + + override fun onCleared() { + isCleared = true + } + + companion object : ViewModelFactory() { + override fun createViewModel( + context: Context, + savedStateHandle: SavedStateHandle + ): SimpleViewModel = SimpleViewModel(savedStateHandle) + } + } +} diff --git a/viewmodel-compose/src/main/AndroidManifest.xml b/viewmodel-compose/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dc4d44d --- /dev/null +++ b/viewmodel-compose/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/viewmodel-compose/src/main/java/com/linecorp/lich/viewmodel/compose/LichViewModel.kt b/viewmodel-compose/src/main/java/com/linecorp/lich/viewmodel/compose/LichViewModel.kt new file mode 100644 index 0000000..ab526e3 --- /dev/null +++ b/viewmodel-compose/src/main/java/com/linecorp/lich/viewmodel/compose/LichViewModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2022 LINE Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.linecorp.lich.viewmodel.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSavedStateRegistryOwner +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.savedstate.SavedStateRegistryOwner +import com.linecorp.lich.viewmodel.AbstractViewModel +import com.linecorp.lich.viewmodel.ViewModelFactory +import com.linecorp.lich.viewmodel.internal.LichViewModels + +/** + * Returns an existing ViewModel or creates a new one, associated with the given + * [viewModelStoreOwner]. + * + * By default, the owner is provided by [LocalViewModelStoreOwner]. It is a Navigation back stack if + * the current composable is in a [NavHost](https://developer.android.com/jetpack/compose/navigation). + * Otherwise, the owner is usually a Fragment or an Activity. + * + * The created ViewModel is associated with the given [viewModelStoreOwner] and will be retained + * as long as the owner is alive (e.g. if it is an Activity, until it is finished or process is + * killed). + * + * This is a sample code: + * ``` + * @Composable + * fun FooScreen(fooViewModel: FooViewModel = lichViewModel(FooViewModel)) { + * // Use fooViewModel here. + * } + * ``` + * + * @param factory A [ViewModelFactory] to create the ViewModel. + * @param viewModelStoreOwner The owner of the ViewModel that controls the scope and lifetime of + * the returned ViewModel. Defaults to using [LocalViewModelStoreOwner]. + */ +@Composable +fun lichViewModel( + factory: ViewModelFactory, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + } +): T { + val context = LocalContext.current + return if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) { + LichViewModels.getViewModelWithDefaultArguments( + context, + viewModelStoreOwner, + viewModelStoreOwner, + factory + ) + } else { + val savedStateRegistryOwner = viewModelStoreOwner as? SavedStateRegistryOwner + ?: LocalSavedStateRegistryOwner.current + LichViewModels.getViewModelWithExplicitArguments( + context, + viewModelStoreOwner, + savedStateRegistryOwner, + factory, + null + ) + } +}