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" }
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
+ )
+ }
+}