공유 ViewModel 레시피

이 레시피에서는 맞춤 NavEntryDecorator를 사용하여 탐색 3의 여러 화면 (항목) 간에 ViewModel을 공유하는 방법을 보여줍니다.

작동 방식

이 예에서는 세 가지 화면을 정의합니다.

  • ParentScreen: 카운터를 증가시키는 버튼을 표시합니다. 카운터 상태는 CounterViewModel에 보관됩니다.
  • ChildScreen: 자체 격리된 상태뿐만 아니라 ParentScreen의 카운터 상태를 업데이트할 수 있는 하위 화면입니다.
  • StandaloneScreen: 자체 격리된 상태만 있는 독립적인 화면입니다.

SharedViewModelStoreNavEntryDecorator

이 레시피의 핵심은 SharedViewModelStoreNavEntryDecorator입니다. 이 데코레이터는 탐색 항목의 ViewModelStore를 관리합니다. 항목에서 ViewModelStore를 공유해야 하는 선택적 '상위' 항목을 지정할 수 있습니다.

SharedViewModelActivity.kt에서 NavDisplay는 이 데코레이터로 구성됩니다.

entryDecorators = listOf(
    rememberSaveableStateHolderNavEntryDecorator(),
    rememberSharedViewModelStoreNavEntryDecorator(),
)

ViewModel 공유

공유를 사용 설정하려면 ChildScreen 항목에서 메타데이터를 사용하여 상위 요소를 명시적으로 정의합니다.

entry<ChildScreen>(
    metadata = SharedViewModelStoreNavEntryDecorator.parent(
        ParentScreen.toContentKey()
    )
) {
    // ...
}

toContentKey() 확장 함수는 상위 NavEntrycontentKey가 상위 요소를 정의할 때와 하위 요소의 메타데이터에서 참조될 때 모두 지정되는 방식을 표준화하는 데 사용됩니다.

ChildScreen에서 상위 CounterViewModel을 요청하는 경우:

val parentViewModel = viewModel<CounterViewModel>(
    viewModelStoreOwner = LocalSharedViewModelStoreOwner.current
)

데코레이터는 ParentScreenViewModelStore를 사용하므로 ParentScreen에서 사용 중인 동일한 인스턴스 를 수신하도록 합니다.

ChildScreen은 기본 LocalViewModelStoreOwner에서 자체 CounterViewModel을 계속 요청할 수 있습니다.

val standaloneViewModel = viewModel<CounterViewModel>()

반면 StandaloneScreen은 상위 요소를 정의하지 않으므로 자체 새 ViewModelStoreCounterViewModel의 새 인스턴스만 가져옵니다.

/*
 * Copyright 2025 The Android Open Source Project
 *
 * 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.example.nav3recipes.sharedviewmodel

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.ViewModelStoreProvider
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreProvider
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavEntryDecorator
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.SaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.get
import androidx.navigation3.runtime.metadata
import androidx.savedstate.compose.LocalSavedStateRegistryOwner

/**
 * Returns a [SharedViewModelStoreNavEntryDecorator] that is remembered across recompositions.
 *
 * @param [viewModelStoreOwner] The [ViewModelStoreOwner] that provides the [ViewModelStore] to
 *   NavEntries
 */
@Composable
fun <T : Any> rememberSharedViewModelStoreNavEntryDecorator(
    viewModelStoreOwner: ViewModelStoreOwner =
        checkNotNull(LocalViewModelStoreOwner.current) {
            "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
        },
): SharedViewModelStoreNavEntryDecorator<T> {
    val viewModelStoreProvider = rememberViewModelStoreProvider(viewModelStoreOwner)
    return remember(viewModelStoreOwner) {
        SharedViewModelStoreNavEntryDecorator(
            viewModelStoreProvider,
        )
    }
}

/**
 * Provides the content of a [NavEntry] with a new [ViewModelStoreOwner] and provides that
 * [ViewModelStoreOwner] as a [LocalViewModelStoreOwner] so that it is available within the content.
 *
 * If the [NavEntry] specifies that it has a parent in its metadata, the parent's
 * [ViewModelStoreOwner] will also be supplied along with the new one. This allows the
 * entry to access both its own [ViewModel] and its parent's [ViewModel]s.
 *
 * This requires the usage of [SaveableStateHolderNavEntryDecorator] to ensure that the [NavEntry]
 * scoped [ViewModel]s can properly provide access to [androidx.lifecycle.SavedStateHandle]s.
 *
 * @see [SharedViewModelStoreNavEntryDecorator.parent]
 *
 * @param [viewModelStoreProvider] The [ViewModelStoreProvider] scoped to
 * the parent [ViewModelStoreOwner]
 */
class SharedViewModelStoreNavEntryDecorator<T : Any>(
    viewModelStoreProvider: ViewModelStoreProvider
) : NavEntryDecorator<T>(
    onPop = { key -> viewModelStoreProvider.clearKey(key) },
    decorate = { entry ->
        val localContentKey = entry.contentKey
        val localOwner =
            rememberViewModelStoreOwner(
                localContentKey,
                viewModelStoreProvider,
                savedStateRegistryOwner = LocalSavedStateRegistryOwner.current,
            )

        val localValues: MutableList<ProvidedValue<*>> = mutableListOf(LocalViewModelStoreOwner provides localOwner)

        // If the entry indicates it has a parent, also provide its parent's ViewModelStore
        val parentContentKey = entry.metadata[ParentKey]
        if (parentContentKey != null) {
            val parentOwner = rememberViewModelStoreOwner(
                    parentContentKey,
                    viewModelStoreProvider,
                    savedStateRegistryOwner = LocalSavedStateRegistryOwner.current,
                )

            localValues.add(LocalSharedViewModelStoreOwner provides parentOwner)
        }
        CompositionLocalProvider(
            values = localValues.toTypedArray()
        ) { entry.Content() }
    },
) {
    companion object {
        /**
         * Use this function to specify a `NavEntry`'s parent. The parent's
         * `ViewModelStoreOwner` will be supplied via `LocalSharedViewModelStoreOwner`
         */
        fun parent(key: Any) = metadata {
            put(ParentKey, key)
        }

        object ParentKey : NavMetadataKey<Any>
    }
}

val LocalSharedViewModelStoreOwner =
    staticCompositionLocalOf<ViewModelStoreOwner> { error("No LocalSharedViewModelStoreOwner provided!") }
/*
 * Copyright 2025 The Android Open Source Project
 *
 * 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.example.nav3recipes.sharedviewmodel

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentRed
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable


@Serializable
private data object ParentScreen : NavKey

@Serializable
private data object ChildScreen : NavKey

@Serializable
private data object StandaloneScreen : NavKey

class SharedViewModelActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)

        setContent {
            val backStack = rememberNavBackStack(ParentScreen)

            NavDisplay(
                backStack = backStack,
                onBack = { backStack.removeLastOrNull() },
                entryDecorators = listOf(
                    rememberSaveableStateHolderNavEntryDecorator(),
                    rememberSharedViewModelStoreNavEntryDecorator(),
                ),
                entryProvider = entryProvider {
                    entry<ParentScreen>(
                        clazzContentKey = { key -> key.toContentKey() },
                    ) {
                        val viewModel = viewModel<CounterViewModel>()

                        ContentRed("Parent screen") {
                            Button(onClick = { viewModel.count++ }) {
                                Text("Count: ${viewModel.count}")
                            }
                            Button(onClick = dropUnlessResumed { backStack.add(ChildScreen) }) {
                                Text("View child screen")
                            }
                        }
                    }
                    entry<ChildScreen>(
                        metadata = SharedViewModelStoreNavEntryDecorator.parent(
                            ParentScreen.toContentKey()
                        )
                    ) {
                        val parentViewModel = viewModel<CounterViewModel>(
                            viewModelStoreOwner = LocalSharedViewModelStoreOwner.current
                        )

                        val standaloneViewModel = viewModel<CounterViewModel>()

                        ContentBlue("Child screen") {
                            Button(onClick = { parentViewModel.count++ }) {
                                Text("Parent count: ${parentViewModel.count}")
                            }
                            Button(onClick = { standaloneViewModel.count++ }) {
                                Text("Standalone Count: ${standaloneViewModel.count}")
                            }
                            Button(onClick = dropUnlessResumed { backStack.add(StandaloneScreen) }) {
                                Text("View standalone screen")
                            }
                        }
                    }
                    entry<StandaloneScreen> {
                        val viewModel = viewModel<CounterViewModel>()

                        ContentGreen("Standalone screen") {
                            Button(onClick = {
                                viewModel.count++
                            }) {
                                Text("Count: ${viewModel.count}")
                            }
                        }
                    }
                }
            )
        }
    }
}

fun NavKey.toContentKey() = this.toString()

class CounterViewModel : ViewModel() {
    var count by mutableIntStateOf(0)
}