From 6fc7e19b1092a26885ddcf297982b7b6003c0ab2 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 25 Apr 2025 11:15:59 -0400 Subject: [PATCH 01/17] Experimenting with nav3 --- build-logic/wrapper/gradle-wrapper.properties | 2 +- gradle/libs.versions.toml | 24 +- gradle/wrapper/gradle-wrapper.properties | 2 +- library/compose/build.gradle.kts | 6 + .../SaveableStateNavEntryDecorator2.kt | 146 +++++++++++ .../ViewModelStoreNavEntryDecorator.kt | 217 ++++++++++++++++ .../treenav/compose/MultiPaneDisplay.kt | 6 +- .../kotlin/com/tunjid/treenav/compose/Nav3.kt | 238 ++++++++++++++++++ sample/common/build.gradle.kts | 3 + settings.gradle.kts | 6 + 10 files changed, 643 insertions(+), 7 deletions(-) create mode 100644 library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt create mode 100644 library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt diff --git a/build-logic/wrapper/gradle-wrapper.properties b/build-logic/wrapper/gradle-wrapper.properties index 49e8bb8..0b2e9a0 100644 --- a/build-logic/wrapper/gradle-wrapper.properties +++ b/build-logic/wrapper/gradle-wrapper.properties @@ -15,7 +15,7 @@ # #Mon Jul 05 07:23:39 EDT 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7254541..b606b46 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -androidGradlePlugin = "8.5.2" +androidGradlePlugin = "8.9.2" androidxActivity = "1.9.2" activity-compose = "1.10.0" androidxAppCompat = "1.7.0" @@ -8,16 +8,17 @@ androidxCore = "1.15.0" androidxCollection = "1.5.0-beta03" androidxCompose = "1.7.0" androidxPaging = "3.3.2" +androidxSavedState = "1.3.0-alpha07" androidxTestCore = "1.6.1" androidxTestExt = "1.2.1" androidxTestRunner = "1.6.2" androidxTestRules = "1.6.1" dokka = "1.8.20" -jetbrainsCompose = "1.8.0-alpha03" -jetbrainsLifecycle = "2.8.4" +jetbrainsCompose = "1.8.0-rc01" +jetbrainsLifecycle = "2.9.0-alpha07" jetbrainsMaterial3Adaptive = "1.0.1" junit4 = "4.13.2" -kotlin = "2.1.0" +kotlin = "2.1.20" kotlinxCoroutines = "1.9.0" kotlinxDatetime = "0.6.1" lifecycle-runtime = "2.8.6" @@ -28,6 +29,9 @@ runner = "1.0.2" espressoCore = "3.0.2" appcompatV7 = "28.0.0" googleMaterial = "1.12.0" +lifecycleViewmodelNavigation3 = "1.0.0-SNAPSHOT" +navigation3 = "0.1.0-SNAPSHOT" +materialIcons = "1.6.11" [libraries] android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -44,6 +48,7 @@ androidx-compose-material-iconsExtended = { group = "androidx.compose.material", androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-ui-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-platform = { group = "androidx.compose.ui", name = "ui-platform" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } @@ -53,12 +58,15 @@ androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adap androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" } androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" } androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } +androidx-savedstate-savedstate = { group = "androidx.compose.savedstate", name = "savedstate", version.ref = "androidxSavedState" } +androidx-savedstate-compose = { group = "androidx.compose.savedstate", name = "savedstate-compose", version.ref = "androidxSavedState" } jetbrains-compose-animation = { group = "org.jetbrains.compose.animation", name = "animation", version.ref = "jetbrainsCompose" } jetbrains-compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "jetbrainsCompose" } jetbrains-compose-foundation-layout = { group = "org.jetbrains.compose.foundation", name = "foundation-layout", version.ref = "jetbrainsCompose" } jetbrains-compose-gradlePlugin = { group = "org.jetbrains.compose", name = "compose-gradle-plugin", version.ref = "jetbrainsCompose" } jetbrains-compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-platform = { group = "org.jetbrains.compose.ui", name = "ui-platform", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-test = { group = "org.jetbrains.compose.ui", name = "ui-test-junit4", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-testManifest = { group = "org.jetbrains.compose.ui", name = "ui-test-manifest", version.ref = "jetbrainsCompose" } #jetbrains-compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling", version.ref = "jetbrainsCompose" } @@ -66,6 +74,8 @@ jetbrains-compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui- jetbrains-compose-ui-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-util = { group = "org.jetbrains.compose.ui", name = "ui-util", version.ref = "jetbrainsCompose" } jetbrains-compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "jetbrainsCompose" } +jetbrains-compose-material-icons-core = { group = "org.jetbrains.compose.material", name = "material-icons-core", version.ref = "materialIcons" } +jetbrains-compose-material-icons-extended = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "materialIcons" } jetbrains-compose-material3-adaptive-navigation-suite = { group = "org.jetbrains.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "jetbrainsCompose" } jetbrains-compose-material3-adaptive = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive", version.ref = "jetbrainsMaterial3Adaptive" } jetbrains-compose-material3-adaptive-layout = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive-layout", version.ref = "jetbrainsMaterial3Adaptive" } @@ -73,6 +83,12 @@ jetbrains-lifecycle-runtime = { group = "org.jetbrains.androidx.lifecycle", name jetbrains-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "jetbrainsLifecycle" } jetbrains-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "jetbrainsLifecycle" } jetbrains-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "jetbrainsLifecycle" } +jetbrains-lifecycle-viewmodel-savedstate = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "jetbrainsLifecycle" } +jetbrains-savedstate-savedstate = { group = "org.jetbrains.androidx.savedstate", name = "savedstate", version.ref = "androidxSavedState" } +jetbrains-savedstate-compose = { group = "org.jetbrains.androidx.savedstate", name = "savedstate-compose", version.ref = "androidxSavedState" } + +androidx-navigation3 = { module = "androidx.navigation3:navigation3", version.ref = "navigation3" } +androidx-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNavigation3" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0200bf9..e785658 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -15,7 +15,7 @@ # #Mon Jul 05 07:23:39 EDT 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/library/compose/build.gradle.kts b/library/compose/build.gradle.kts index 75b9b7d..951f5f4 100644 --- a/library/compose/build.gradle.kts +++ b/library/compose/build.gradle.kts @@ -43,6 +43,12 @@ kotlin { implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.jetbrains.lifecycle.viewmodel.compose) + + + implementation(libs.androidx.navigation3) +// implementation(libs.androidx.viewmodel.navigation3) + implementation(libs.jetbrains.savedstate.compose) + } } commonTest { diff --git a/library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt b/library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt new file mode 100644 index 0000000..8be6ac5 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2024 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 androidx.navigation3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.staticCompositionLocalOf +import kotlin.collections.LinkedHashSet + +/** + * Wraps the content of a [NavEntry] with a [SaveableStateHolder.SaveableStateProvider] to ensure + * that calls to [rememberSaveable] within the content work properly and that state can be saved. + * + * This [NavEntryDecorator] is the only one that is **required** as saving state is considered a + * non-optional feature. + */ +public object SaveableStateNavEntryDecorator2 : NavEntryDecorator { + + @Composable + override fun DecorateBackStack(backStack: List, content: @Composable () -> Unit) { + val localInfo = remember { SaveableStateNavLocalInfo() } + DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } + + localInfo.savedStateHolder = rememberSaveableStateHolder() + backStack.forEachIndexed { index, key -> + // We update here as part of composition to ensure the value is available to + // DecorateEntry + localInfo.refCount.getOrPut(key) { LinkedHashSet() }.add(getIdForKey(key, index)) + DisposableEffect(key1 = key) { + // We update here at the end of composition in case the backstack changed and + // everything was cleared. + localInfo.refCount + .getOrPut(key) { LinkedHashSet() } + .add(getIdForKey(key, index)) + onDispose { + // If the backStack count is less than the refCount for the key, remove the + // state since that means we removed a key from the backstack, and set the + // refCount to the backstack count. + val backstackCount = backStack.count { it == key } + val lastKeyCount = localInfo.refCount[key]?.size ?: 0 + if (backstackCount < lastKeyCount) { + // The set of the ids associated with this key + @Suppress("PrimitiveInCollection") // The order of the element matters + val idsSet = localInfo.refCount[key]!! + val id = idsSet.last() + idsSet.remove(id) + if (!localInfo.idsInComposition.contains(id)) { + localInfo.savedStateHolder!!.removeState(id) + } + } + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + CompositionLocalProvider(LocalSaveableStateNavLocalInfo provides localInfo) { + content.invoke() + } + } + + @Composable + public override fun DecorateEntry(entry: NavEntry) { + val localInfo = LocalSaveableStateNavLocalInfo.current + val key = entry.key + // Tracks whether the key is changed + var keyChanged = false + var id: Int = + rememberSaveable(key) { + keyChanged = true + localInfo.refCount[key]!!.last() + } + id = + rememberSaveable(localInfo.refCount[key]?.size) { + // if the key changed, use the current id + // If the key was not changed, and the current id is not in composition or on the + // back + // stack then update the id with the last item from the backstack with the + // associated + // key. This ensures that we can handle duplicates, both consecutive and + // non-consecutive + if ( + !keyChanged && + (!localInfo.idsInComposition.contains(id) || + localInfo.refCount[key]?.contains(id) == true) + ) { + localInfo.refCount[key]!!.last() + } else { + id + } + } + keyChanged = false + DisposableEffect(key1 = key) { + localInfo.idsInComposition.add(id) + onDispose { + if (localInfo.idsInComposition.remove(id) && !localInfo.refCount.contains(key)) { + localInfo.savedStateHolder!!.removeState(id) + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + localInfo.savedStateHolder?.SaveableStateProvider(id) { entry.content.invoke(key) } + } +} + +internal val LocalSaveableStateNavLocalInfo = + staticCompositionLocalOf { + error( + "CompositionLocal LocalSaveableStateNavLocalInfo not present. You must call " + + "DecorateBackStack before calling DecorateEntry." + ) + } + +internal class SaveableStateNavLocalInfo { + internal var savedStateHolder: SaveableStateHolder? = null + internal val refCount: MutableMap> = mutableMapOf() + @Suppress("PrimitiveInCollection") // The order of the element matters + internal val idsInComposition: LinkedHashSet = LinkedHashSet() +} + +internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count diff --git a/library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt new file mode 100644 index 0000000..644fc78 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2024 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 androidx.lifecycle.viewmodel.navigation3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.SavedStateViewModelFactory +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.NavEntry +import androidx.navigation3.NavEntryDecorator +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner + +/** + * Provides the content of a [NavEntry] with a [ViewModelStoreOwner] and provides that + * [ViewModelStoreOwner] as a [LocalViewModelStoreOwner] so that it is available within the content. + * + * This requires that usage of the [SavedStateNavEntryDecorator] to ensure that the [NavEntry] + * scoped [ViewModel]s can properly provide access to [SavedStateHandle]s + */ +public object ViewModelStoreNavEntryDecorator : NavEntryDecorator { + + @Composable + override fun DecorateBackStack(backStack: List, content: @Composable () -> Unit) { + val entryViewModelStoreProvider = viewModel { EntryViewModel() } + entryViewModelStoreProvider.ownerInBackStack.clear() + entryViewModelStoreProvider.ownerInBackStack.addAll(backStack) + val localInfo = remember { ViewModelStoreNavLocalInfo() } + DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } + +// val activity = LocalActivity.current + backStack.forEachIndexed { index, key -> + // We update here as part of composition to ensure the value is available to + // DecorateEntry + localInfo.refCount.getOrPut(key) { LinkedHashSet() }.add(getIdForKey(key, index)) + DisposableEffect(key1 = key) { + localInfo.refCount + .getOrPut(key) { LinkedHashSet() } + .add(getIdForKey(key, index)) + onDispose { + // If the backStack count is less than the refCount for the key, remove the + // state since that means we removed a key from the backstack, and set the + // refCount to the backstack count. + val backstackCount = backStack.count { it == key } + val lastKeyCount = localInfo.refCount[key]?.size ?: 0 + if (backstackCount < lastKeyCount) { + // The set of the ids associated with this key + @Suppress("PrimitiveInCollection") // The order of the element matters + val idsSet = localInfo.refCount[key]!! + val id = idsSet.last() + idsSet.remove(id) + if (!localInfo.idsInComposition.contains(id)) { +// if (activity?.isChangingConfigurations != true) { + entryViewModelStoreProvider + .removeViewModelStoreOwnerForKey(id) + ?.clear() +// } + } + } + + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + CompositionLocalProvider(LocalViewModelStoreNavLocalInfo provides localInfo) { + content.invoke() + } + } + + @Composable + override fun DecorateEntry(entry: NavEntry) { + val key = entry.key + val entryViewModelStoreProvider = viewModel { EntryViewModel() } + +// val activity = LocalActivity.current + val localInfo = LocalViewModelStoreNavLocalInfo.current + // Tracks whether the key is changed + var keyChanged = false + var id: Int = + rememberSaveable(key) { + keyChanged = true + localInfo.refCount[key]!!.last() + } + id = + rememberSaveable(localInfo.refCount[key]?.size) { + // if the key changed, use the current id + // If the key was not changed, and the current id is not in composition or on the + // back stack then update the id with the last item from the backstack with the + // associated key. This ensures that we can handle duplicates, both consecutive and + // non-consecutive + if ( + !keyChanged && + (!localInfo.idsInComposition.contains(id) || + localInfo.refCount[key]?.contains(id) == true) + ) { + localInfo.refCount[key]!!.last() + } else { + id + } + } + keyChanged = false + + val viewModelStore = entryViewModelStoreProvider.viewModelStoreForKey(id) + + DisposableEffect(key1 = key) { + localInfo.idsInComposition.add(id) + onDispose { + if (localInfo.idsInComposition.remove(id) && !localInfo.refCount.contains(key)) { +// if (activity?.isChangingConfigurations != true) { + entryViewModelStoreProvider.removeViewModelStoreOwnerForKey(id)?.clear() +// } + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + val childViewModelOwner = remember { + object : + ViewModelStoreOwner, + SavedStateRegistryOwner by savedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + override val viewModelStore: ViewModelStore + get() = viewModelStore + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = SavedStateViewModelFactory() + + override val defaultViewModelCreationExtras: CreationExtras + get() = + MutableCreationExtras().also { + it[SAVED_STATE_REGISTRY_OWNER_KEY] = savedStateRegistryOwner + it[VIEW_MODEL_STORE_OWNER_KEY] = this + } + + init { + require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) { + "The Lifecycle state is already beyond INITIALIZED. The " + + "ViewModelStoreNavEntryDecorator requires adding the " + + "SavedStateNavEntryDecorator to ensure support for " + + "SavedStateHandles." + } + enableSavedStateHandles() + } + } + } + CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelOwner) { + entry.content.invoke(key) + } + } +} + +private class EntryViewModel : ViewModel() { + private val owners = mutableMapOf() + val ownerInBackStack = mutableListOf() + + fun viewModelStoreForKey(key: Any): ViewModelStore = owners.getOrPut(key) { ViewModelStore() } + + fun removeViewModelStoreOwnerForKey(key: Any): ViewModelStore? = owners.remove(key) + + override fun onCleared() { + owners.forEach { (_, store) -> store.clear() } + } +} + +internal val LocalViewModelStoreNavLocalInfo = + staticCompositionLocalOf { + error( + "CompositionLocal LocalViewModelStoreNavLocalInfo not present. You must call " + + "DecorateBackStack before calling DecorateEntry." + ) + } + +internal class ViewModelStoreNavLocalInfo { + internal val refCount: MutableMap> = mutableMapOf() + @Suppress("PrimitiveInCollection") // The order of the element matters + internal val idsInComposition: LinkedHashSet = LinkedHashSet() +} + +internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 8135b2e..7b8681a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -79,7 +79,11 @@ fun MultiPaneDisplay( Box( modifier = modifier ) { - SlottedMultiPaneDisplayScope( +// SlottedMultiPaneDisplayScope( +// state = state, +// content = content +// ) + Navigation3MultiPaneDisplayScope( state = state, content = content ) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt new file mode 100644 index 0000000..e490e13 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2021 Google LLC + * + * 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.tunjid.treenav.compose + + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.updateTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavEntryDecorator +import androidx.navigation3.DecoratedNavEntryProvider +import androidx.navigation3.NavEntry +import androidx.navigation3.SaveableStateNavEntryDecorator +import androidx.navigation3.SavedStateNavEntryDecorator +import com.tunjid.treenav.Node + +@Composable +internal fun Navigation3MultiPaneDisplayScope( + state: MultiPaneDisplayState, + content: @Composable (MultiPaneDisplayScope.() -> Unit), +) { + val backStack by remember { + derivedStateOf { + state.backStackTransform(state.navigationState.value) + } + } + val panesToNodes = state.panesToDestinationsTransform(state.currentDestination.value) + + DecoratedNavEntryProvider( + backStack = backStack, + entryProvider = { node -> + NavEntry( + key = node, + content = { destination -> + val scope = LocalPaneScope.current + @Suppress("UNCHECKED_CAST") + state.renderTransform(scope as PaneScope, destination) + } + ) + }, + entryDecorators = listOf( + SaveableStateNavEntryDecorator, + SavedStateNavEntryDecorator, + ViewModelStoreNavEntryDecorator, + ), + content = { entries -> + val updatedEntries by rememberUpdatedState(entries) + val displayScope = remember { + Navigation3MultiPaneDisplayScope( + panes = state.panes, + initialBackStack = backStack, + initialPanesToNodes = panesToNodes, + paneRenderer = { + val currentEntry = remember(paneState.currentDestination?.id) { + updatedEntries.findLast { + it.key.id == paneState.currentDestination?.id + } + } + currentEntry?.content?.invoke(currentEntry.key) ?: println("COULD NOT FIND") + }, + ) + } + DisposableEffect(backStack, panesToNodes) { + displayScope.onBackStackChanged( + backStack = backStack, + panesToNodes = panesToNodes + ) + onDispose { } + } + + displayScope.content() + }, + ) +} + +@Stable +private class Navigation3MultiPaneDisplayScope( + panes: List, + initialBackStack: List, + initialPanesToNodes: Map, + private val paneRenderer: @Composable (PaneScope.() -> Unit), +) : MultiPaneDisplayScope { + + private val slots = List( + size = panes.size, + init = ::Slot + ).toSet() + + var panedNavigationState by mutableStateOf( + value = SlotBasedPanedNavigationState.initial(slots = slots) + .adaptTo( + slots = slots, + panesToNodes = initialPanesToNodes, + backStackIds = initialBackStack.ids(), + ) + ) + + private val slotsToRoutes = + mutableStateMapOf Unit>().also { map -> + map[null] = {} + slots.forEach { slot -> + map[slot] = movableContentOf { Render(slot) } + } + } + + @Composable + override fun Destination(pane: Pane) { + val slot = panedNavigationState.slotFor(pane) + slotsToRoutes[slot]?.invoke() + } + + override fun adaptationsIn( + pane: Pane, + ): Set = panedNavigationState.adaptationsIn(pane) + + override fun destinationIn( + pane: Pane, + ): Destination? = panedNavigationState.destinationFor(pane) + + fun onBackStackChanged( + backStack: List, + panesToNodes: Map, + ) { + updateAdaptiveNavigationState { + adaptTo( + slots = slots.toSet(), + panesToNodes = panesToNodes, + backStackIds = backStack.ids() + ) + } + } + + /** + * Renders [slot] into its pane with scopes that allow for animations + * and shared elements. + */ + @Composable + private fun Render( + slot: Slot, + ) { + val paneTransition = updateTransition( + targetState = panedNavigationState.paneStateFor(slot), + label = "$slot-PaneTransition", + ) + paneTransition.AnimatedContent( + contentKey = { it.currentDestination?.id }, +// transitionSpec = { +// ContentTransform( +// targetContentEnter = EnterTransition.None, +// initialContentExit = ExitTransition.None, +// sizeTransform = null, +// ) +// } + ) { targetPaneState -> + val scope = remember { + AnimatedPaneScope( + paneState = targetPaneState, + activeState = derivedStateOf { + val activePaneState = panedNavigationState.paneStateFor(slot) + activePaneState.currentDestination?.id == targetPaneState.currentDestination?.id + }, + animatedContentScope = this@AnimatedContent, + ) + } + + // While technically a backwards write, it stabilizes and ensures the values are + // correct at first composition + scope.paneState = targetPaneState + + val destination = targetPaneState.currentDestination + if (destination != null) { + + CompositionLocalProvider( + LocalPaneScope provides scope + ) { + scope.paneRenderer() + } + } + + + // Remove route ids that have animated out +// DisposableEffect(Unit) { +// val inRouteId = targetPaneState.currentDestination?.id +// println("IN: $inRouteId") +// onDispose { +// println("OUT: $inRouteId") +// } +// } + } + } + + private inline fun updateAdaptiveNavigationState( + block: SlotBasedPanedNavigationState.() -> SlotBasedPanedNavigationState, + ) { + panedNavigationState = panedNavigationState.block() + } + + private fun List.ids(): MutableSet = + fold(mutableSetOf()) { set, destination -> + set.add(destination.id) + set + } +} + +private val LocalPaneScope = staticCompositionLocalOf> { + TODO() +} \ No newline at end of file diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts index 09d7a3c..cf2d9f7 100755 --- a/sample/common/build.gradle.kts +++ b/sample/common/build.gradle.kts @@ -55,6 +55,9 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite) + implementation(libs.jetbrains.compose.material.icons.core) + implementation(libs.jetbrains.compose.material.icons.extended) + implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) diff --git a/settings.gradle.kts b/settings.gradle.kts index 6504b4a..95e7e3f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,9 @@ pluginManagement { mavenCentral() gradlePluginPortal() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + maven { + url = uri("https://androidx.dev/snapshots/builds/13407944/artifacts/repository") + } } } @@ -31,6 +34,9 @@ dependencyResolutionManagement { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + maven { + url = uri("https://androidx.dev/snapshots/builds/13407944/artifacts/repository") + } } } From c1b6e8fa4b4a4b79b938a403726df9cac9233701 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 25 Apr 2025 15:04:19 -0400 Subject: [PATCH 02/17] Make backstack SnapshotStateList --- .../SaveableStateNavEntryDecorator2.kt | 146 ------------------ .../kotlin/com/tunjid/treenav/compose/Nav3.kt | 19 +-- 2 files changed, 10 insertions(+), 155 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt diff --git a/library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt b/library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt deleted file mode 100644 index 8be6ac5..0000000 --- a/library/compose/src/commonMain/kotlin/androidx/navigation3/SaveableStateNavEntryDecorator2.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2024 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 androidx.navigation3 - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.SaveableStateHolder -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.staticCompositionLocalOf -import kotlin.collections.LinkedHashSet - -/** - * Wraps the content of a [NavEntry] with a [SaveableStateHolder.SaveableStateProvider] to ensure - * that calls to [rememberSaveable] within the content work properly and that state can be saved. - * - * This [NavEntryDecorator] is the only one that is **required** as saving state is considered a - * non-optional feature. - */ -public object SaveableStateNavEntryDecorator2 : NavEntryDecorator { - - @Composable - override fun DecorateBackStack(backStack: List, content: @Composable () -> Unit) { - val localInfo = remember { SaveableStateNavLocalInfo() } - DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } - - localInfo.savedStateHolder = rememberSaveableStateHolder() - backStack.forEachIndexed { index, key -> - // We update here as part of composition to ensure the value is available to - // DecorateEntry - localInfo.refCount.getOrPut(key) { LinkedHashSet() }.add(getIdForKey(key, index)) - DisposableEffect(key1 = key) { - // We update here at the end of composition in case the backstack changed and - // everything was cleared. - localInfo.refCount - .getOrPut(key) { LinkedHashSet() } - .add(getIdForKey(key, index)) - onDispose { - // If the backStack count is less than the refCount for the key, remove the - // state since that means we removed a key from the backstack, and set the - // refCount to the backstack count. - val backstackCount = backStack.count { it == key } - val lastKeyCount = localInfo.refCount[key]?.size ?: 0 - if (backstackCount < lastKeyCount) { - // The set of the ids associated with this key - @Suppress("PrimitiveInCollection") // The order of the element matters - val idsSet = localInfo.refCount[key]!! - val id = idsSet.last() - idsSet.remove(id) - if (!localInfo.idsInComposition.contains(id)) { - localInfo.savedStateHolder!!.removeState(id) - } - } - // If the refCount is 0, remove the key from the refCount. - if (localInfo.refCount[key]?.isEmpty() == true) { - localInfo.refCount.remove(key) - } - } - } - } - - CompositionLocalProvider(LocalSaveableStateNavLocalInfo provides localInfo) { - content.invoke() - } - } - - @Composable - public override fun DecorateEntry(entry: NavEntry) { - val localInfo = LocalSaveableStateNavLocalInfo.current - val key = entry.key - // Tracks whether the key is changed - var keyChanged = false - var id: Int = - rememberSaveable(key) { - keyChanged = true - localInfo.refCount[key]!!.last() - } - id = - rememberSaveable(localInfo.refCount[key]?.size) { - // if the key changed, use the current id - // If the key was not changed, and the current id is not in composition or on the - // back - // stack then update the id with the last item from the backstack with the - // associated - // key. This ensures that we can handle duplicates, both consecutive and - // non-consecutive - if ( - !keyChanged && - (!localInfo.idsInComposition.contains(id) || - localInfo.refCount[key]?.contains(id) == true) - ) { - localInfo.refCount[key]!!.last() - } else { - id - } - } - keyChanged = false - DisposableEffect(key1 = key) { - localInfo.idsInComposition.add(id) - onDispose { - if (localInfo.idsInComposition.remove(id) && !localInfo.refCount.contains(key)) { - localInfo.savedStateHolder!!.removeState(id) - // If the refCount is 0, remove the key from the refCount. - if (localInfo.refCount[key]?.isEmpty() == true) { - localInfo.refCount.remove(key) - } - } - } - } - - localInfo.savedStateHolder?.SaveableStateProvider(id) { entry.content.invoke(key) } - } -} - -internal val LocalSaveableStateNavLocalInfo = - staticCompositionLocalOf { - error( - "CompositionLocal LocalSaveableStateNavLocalInfo not present. You must call " + - "DecorateBackStack before calling DecorateEntry." - ) - } - -internal class SaveableStateNavLocalInfo { - internal var savedStateHolder: SaveableStateHolder? = null - internal val refCount: MutableMap> = mutableMapOf() - @Suppress("PrimitiveInCollection") // The order of the element matters - internal val idsInComposition: LinkedHashSet = LinkedHashSet() -} - -internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt index e490e13..c153f06 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt @@ -18,20 +18,15 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.updateTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,9 +45,10 @@ internal fun Navigation3Multi state: MultiPaneDisplayState, content: @Composable (MultiPaneDisplayScope.() -> Unit), ) { - val backStack by remember { - derivedStateOf { - state.backStackTransform(state.navigationState.value) + val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> + state.backStackTransform(state.navigationState.value).let { currentBackStack -> + mutableBackStack.clear() + mutableBackStack.addAll(currentBackStack) } } val panesToNodes = state.panesToDestinationsTransform(state.currentDestination.value) @@ -235,4 +231,9 @@ private class Navigation3MultiPaneDisplayScope( private val LocalPaneScope = staticCompositionLocalOf> { TODO() +} + +@Composable +private fun rememberUpdatedSnapshotList() { + } \ No newline at end of file From dff5528f5d14a427245c13e82478fff39848eac4 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 25 Apr 2025 19:50:07 -0400 Subject: [PATCH 03/17] Clean up Nav3 code --- ...DecoratedNavEntryMultiPaneDisplayScope.kt} | 39 +++++++------------ .../treenav/compose/MultiPaneDisplay.kt | 2 +- 2 files changed, 15 insertions(+), 26 deletions(-) rename library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/{Nav3.kt => DecoratedNavEntryMultiPaneDisplayScope.kt} (90%) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt similarity index 90% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index c153f06..6a09859 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Nav3.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -18,6 +18,9 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.updateTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -41,7 +44,7 @@ import androidx.navigation3.SavedStateNavEntryDecorator import com.tunjid.treenav.Node @Composable -internal fun Navigation3MultiPaneDisplayScope( +internal fun DecoratedNavEntryMultiPaneDisplayScope( state: MultiPaneDisplayState, content: @Composable (MultiPaneDisplayScope.() -> Unit), ) { @@ -83,7 +86,9 @@ internal fun Navigation3Multi it.key.id == paneState.currentDestination?.id } } - currentEntry?.content?.invoke(currentEntry.key) ?: println("COULD NOT FIND") + checkNotNull(currentEntry) { + "There is no entry for the current navigation destination with id ${paneState.currentDestination?.id}" + }.content(currentEntry.key) }, ) } @@ -171,13 +176,13 @@ private class Navigation3MultiPaneDisplayScope( ) paneTransition.AnimatedContent( contentKey = { it.currentDestination?.id }, -// transitionSpec = { -// ContentTransform( -// targetContentEnter = EnterTransition.None, -// initialContentExit = ExitTransition.None, -// sizeTransform = null, -// ) -// } + transitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + sizeTransform = null, + ) + } ) { targetPaneState -> val scope = remember { AnimatedPaneScope( @@ -196,23 +201,12 @@ private class Navigation3MultiPaneDisplayScope( val destination = targetPaneState.currentDestination if (destination != null) { - CompositionLocalProvider( LocalPaneScope provides scope ) { scope.paneRenderer() } } - - - // Remove route ids that have animated out -// DisposableEffect(Unit) { -// val inRouteId = targetPaneState.currentDestination?.id -// println("IN: $inRouteId") -// onDispose { -// println("OUT: $inRouteId") -// } -// } } } @@ -232,8 +226,3 @@ private class Navigation3MultiPaneDisplayScope( private val LocalPaneScope = staticCompositionLocalOf> { TODO() } - -@Composable -private fun rememberUpdatedSnapshotList() { - -} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 7b8681a..239b154 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -83,7 +83,7 @@ fun MultiPaneDisplay( // state = state, // content = content // ) - Navigation3MultiPaneDisplayScope( + DecoratedNavEntryMultiPaneDisplayScope( state = state, content = content ) From 7e6a83a2895f7f54e31a31462f38d3edf8226a55 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 26 Apr 2025 10:25:33 -0400 Subject: [PATCH 04/17] Add android target and pull in ViewModelStoreNavEntryDecorator --- library/compose/build.gradle.kts | 10 +- ...edNavEntryMultiPaneDisplayScope.android.kt | 24 ++ .../ViewModelStoreNavEntryDecorator.kt | 217 ------------------ .../DecoratedNavEntryMultiPaneDisplayScope.kt | 204 +++++++++++++++- ...oratedNavEntryMultiPaneDisplayScope.jvm.kt | 23 ++ ...tedNavEntryMultiPaneDisplayScope.native.kt | 23 ++ 6 files changed, 280 insertions(+), 221 deletions(-) create mode 100644 library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.android.kt delete mode 100644 library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt create mode 100644 library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.jvm.kt create mode 100644 library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.native.kt diff --git a/library/compose/build.gradle.kts b/library/compose/build.gradle.kts index 951f5f4..f67dfe3 100644 --- a/library/compose/build.gradle.kts +++ b/library/compose/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("multiplatform") id("publishing-library-convention") + id("android-library-convention") id("kotlin-jvm-convention") id("maven-publish") signing @@ -11,8 +12,8 @@ plugins { kotlin { applyDefaultHierarchyTemplate() + androidTarget() jvm { - withJava() testRuns["test"].executionTask.configure { useJUnit() } @@ -46,11 +47,16 @@ kotlin { implementation(libs.androidx.navigation3) -// implementation(libs.androidx.viewmodel.navigation3) implementation(libs.jetbrains.savedstate.compose) } } + androidMain { + dependencies { + implementation(libs.androidx.viewmodel.navigation3) + } + } + commonTest { dependencies { implementation(kotlin("test")) diff --git a/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.android.kt b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.android.kt new file mode 100644 index 0000000..01301ca --- /dev/null +++ b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.android.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Google LLC + * + * 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.tunjid.treenav.compose + +import androidx.compose.runtime.Stable +import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavEntryDecorator + +@Stable +internal actual val PlatformViewModelStoreNavEntryDecorator: Any? + get() = ViewModelStoreNavEntryDecorator \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt deleted file mode 100644 index 644fc78..0000000 --- a/library/compose/src/commonMain/kotlin/androidx/navigation3/ViewModelStoreNavEntryDecorator.kt +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2024 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 androidx.lifecycle.viewmodel.navigation3 - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.lifecycle.HasDefaultViewModelProviderFactory -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY -import androidx.lifecycle.SavedStateViewModelFactory -import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.enableSavedStateHandles -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation3.NavEntry -import androidx.navigation3.NavEntryDecorator -import androidx.savedstate.SavedStateRegistryOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner - -/** - * Provides the content of a [NavEntry] with a [ViewModelStoreOwner] and provides that - * [ViewModelStoreOwner] as a [LocalViewModelStoreOwner] so that it is available within the content. - * - * This requires that usage of the [SavedStateNavEntryDecorator] to ensure that the [NavEntry] - * scoped [ViewModel]s can properly provide access to [SavedStateHandle]s - */ -public object ViewModelStoreNavEntryDecorator : NavEntryDecorator { - - @Composable - override fun DecorateBackStack(backStack: List, content: @Composable () -> Unit) { - val entryViewModelStoreProvider = viewModel { EntryViewModel() } - entryViewModelStoreProvider.ownerInBackStack.clear() - entryViewModelStoreProvider.ownerInBackStack.addAll(backStack) - val localInfo = remember { ViewModelStoreNavLocalInfo() } - DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } - -// val activity = LocalActivity.current - backStack.forEachIndexed { index, key -> - // We update here as part of composition to ensure the value is available to - // DecorateEntry - localInfo.refCount.getOrPut(key) { LinkedHashSet() }.add(getIdForKey(key, index)) - DisposableEffect(key1 = key) { - localInfo.refCount - .getOrPut(key) { LinkedHashSet() } - .add(getIdForKey(key, index)) - onDispose { - // If the backStack count is less than the refCount for the key, remove the - // state since that means we removed a key from the backstack, and set the - // refCount to the backstack count. - val backstackCount = backStack.count { it == key } - val lastKeyCount = localInfo.refCount[key]?.size ?: 0 - if (backstackCount < lastKeyCount) { - // The set of the ids associated with this key - @Suppress("PrimitiveInCollection") // The order of the element matters - val idsSet = localInfo.refCount[key]!! - val id = idsSet.last() - idsSet.remove(id) - if (!localInfo.idsInComposition.contains(id)) { -// if (activity?.isChangingConfigurations != true) { - entryViewModelStoreProvider - .removeViewModelStoreOwnerForKey(id) - ?.clear() -// } - } - } - - // If the refCount is 0, remove the key from the refCount. - if (localInfo.refCount[key]?.isEmpty() == true) { - localInfo.refCount.remove(key) - } - } - } - } - - CompositionLocalProvider(LocalViewModelStoreNavLocalInfo provides localInfo) { - content.invoke() - } - } - - @Composable - override fun DecorateEntry(entry: NavEntry) { - val key = entry.key - val entryViewModelStoreProvider = viewModel { EntryViewModel() } - -// val activity = LocalActivity.current - val localInfo = LocalViewModelStoreNavLocalInfo.current - // Tracks whether the key is changed - var keyChanged = false - var id: Int = - rememberSaveable(key) { - keyChanged = true - localInfo.refCount[key]!!.last() - } - id = - rememberSaveable(localInfo.refCount[key]?.size) { - // if the key changed, use the current id - // If the key was not changed, and the current id is not in composition or on the - // back stack then update the id with the last item from the backstack with the - // associated key. This ensures that we can handle duplicates, both consecutive and - // non-consecutive - if ( - !keyChanged && - (!localInfo.idsInComposition.contains(id) || - localInfo.refCount[key]?.contains(id) == true) - ) { - localInfo.refCount[key]!!.last() - } else { - id - } - } - keyChanged = false - - val viewModelStore = entryViewModelStoreProvider.viewModelStoreForKey(id) - - DisposableEffect(key1 = key) { - localInfo.idsInComposition.add(id) - onDispose { - if (localInfo.idsInComposition.remove(id) && !localInfo.refCount.contains(key)) { -// if (activity?.isChangingConfigurations != true) { - entryViewModelStoreProvider.removeViewModelStoreOwnerForKey(id)?.clear() -// } - // If the refCount is 0, remove the key from the refCount. - if (localInfo.refCount[key]?.isEmpty() == true) { - localInfo.refCount.remove(key) - } - } - } - } - - val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current - val childViewModelOwner = remember { - object : - ViewModelStoreOwner, - SavedStateRegistryOwner by savedStateRegistryOwner, - HasDefaultViewModelProviderFactory { - override val viewModelStore: ViewModelStore - get() = viewModelStore - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = SavedStateViewModelFactory() - - override val defaultViewModelCreationExtras: CreationExtras - get() = - MutableCreationExtras().also { - it[SAVED_STATE_REGISTRY_OWNER_KEY] = savedStateRegistryOwner - it[VIEW_MODEL_STORE_OWNER_KEY] = this - } - - init { - require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) { - "The Lifecycle state is already beyond INITIALIZED. The " + - "ViewModelStoreNavEntryDecorator requires adding the " + - "SavedStateNavEntryDecorator to ensure support for " + - "SavedStateHandles." - } - enableSavedStateHandles() - } - } - } - CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelOwner) { - entry.content.invoke(key) - } - } -} - -private class EntryViewModel : ViewModel() { - private val owners = mutableMapOf() - val ownerInBackStack = mutableListOf() - - fun viewModelStoreForKey(key: Any): ViewModelStore = owners.getOrPut(key) { ViewModelStore() } - - fun removeViewModelStoreOwnerForKey(key: Any): ViewModelStore? = owners.remove(key) - - override fun onCleared() { - owners.forEach { (_, store) -> store.clear() } - } -} - -internal val LocalViewModelStoreNavLocalInfo = - staticCompositionLocalOf { - error( - "CompositionLocal LocalViewModelStoreNavLocalInfo not present. You must call " + - "DecorateBackStack before calling DecorateEntry." - ) - } - -internal class ViewModelStoreNavLocalInfo { - internal val refCount: MutableMap> = mutableMapOf() - @Suppress("PrimitiveInCollection") // The order of the element matters - internal val idsInComposition: LinkedHashSet = LinkedHashSet() -} - -internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index 6a09859..aa7ce89 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -34,13 +34,30 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf -import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavEntryDecorator +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.SavedStateViewModelFactory +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.DecoratedNavEntryProvider import androidx.navigation3.NavEntry +import androidx.navigation3.NavEntryDecorator import androidx.navigation3.SaveableStateNavEntryDecorator import androidx.navigation3.SavedStateNavEntryDecorator +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner import com.tunjid.treenav.Node @Composable @@ -71,7 +88,7 @@ internal fun DecoratedNavEntr entryDecorators = listOf( SaveableStateNavEntryDecorator, SavedStateNavEntryDecorator, - ViewModelStoreNavEntryDecorator, + CastPlatformViewModelStoreNavEntryDecorator, ), content = { entries -> val updatedEntries by rememberUpdatedState(entries) @@ -226,3 +243,186 @@ private class Navigation3MultiPaneDisplayScope( private val LocalPaneScope = staticCompositionLocalOf> { TODO() } + +@Stable +private val CastPlatformViewModelStoreNavEntryDecorator: NavEntryDecorator + get() = PlatformViewModelStoreNavEntryDecorator as? NavEntryDecorator + ?: DefaultViewModelStoreNavEntryDecorator + +@Stable +internal expect val PlatformViewModelStoreNavEntryDecorator: Any? + +/** + * Provides the content of a [NavEntry] with a [ViewModelStoreOwner] and provides that + * [ViewModelStoreOwner] as a [LocalViewModelStoreOwner] so that it is available within the content. + * + * This requires that usage of the [SavedStateNavEntryDecorator] to ensure that the [NavEntry] + * scoped [ViewModel]s can properly provide access to [SavedStateHandle]s + */ +internal object DefaultViewModelStoreNavEntryDecorator : NavEntryDecorator { + + @Composable + override fun DecorateBackStack(backStack: List, content: @Composable () -> Unit) { + val entryViewModelStoreProvider = viewModel { EntryViewModel() } + entryViewModelStoreProvider.ownerInBackStack.clear() + entryViewModelStoreProvider.ownerInBackStack.addAll(backStack) + val localInfo = remember { ViewModelStoreNavLocalInfo() } + DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } + +// val activity = LocalActivity.current + backStack.forEachIndexed { index, key -> + // We update here as part of composition to ensure the value is available to + // DecorateEntry + localInfo.refCount.getOrPut(key) { LinkedHashSet() }.add(getIdForKey(key, index)) + DisposableEffect(key1 = key) { + localInfo.refCount + .getOrPut(key) { LinkedHashSet() } + .add(getIdForKey(key, index)) + onDispose { + // If the backStack count is less than the refCount for the key, remove the + // state since that means we removed a key from the backstack, and set the + // refCount to the backstack count. + val backstackCount = backStack.count { it == key } + val lastKeyCount = localInfo.refCount[key]?.size ?: 0 + if (backstackCount < lastKeyCount) { + // The set of the ids associated with this key + @Suppress("PrimitiveInCollection") // The order of the element matters + val idsSet = localInfo.refCount[key]!! + val id = idsSet.last() + idsSet.remove(id) + if (!localInfo.idsInComposition.contains(id)) { +// if (activity?.isChangingConfigurations != true) { + entryViewModelStoreProvider + .removeViewModelStoreOwnerForKey(id) + ?.clear() +// } + } + } + + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + CompositionLocalProvider(LocalViewModelStoreNavLocalInfo provides localInfo) { + content.invoke() + } + } + + @Composable + override fun DecorateEntry(entry: NavEntry) { + val key = entry.key + val entryViewModelStoreProvider = viewModel { EntryViewModel() } + +// val activity = LocalActivity.current + val localInfo = LocalViewModelStoreNavLocalInfo.current + // Tracks whether the key is changed + var keyChanged = false + var id: Int = + rememberSaveable(key) { + keyChanged = true + localInfo.refCount[key]!!.last() + } + id = + rememberSaveable(localInfo.refCount[key]?.size) { + // if the key changed, use the current id + // If the key was not changed, and the current id is not in composition or on the + // back stack then update the id with the last item from the backstack with the + // associated key. This ensures that we can handle duplicates, both consecutive and + // non-consecutive + if ( + !keyChanged && + (!localInfo.idsInComposition.contains(id) || + localInfo.refCount[key]?.contains(id) == true) + ) { + localInfo.refCount[key]!!.last() + } else { + id + } + } + keyChanged = false + + val viewModelStore = entryViewModelStoreProvider.viewModelStoreForKey(id) + + DisposableEffect(key1 = key) { + localInfo.idsInComposition.add(id) + onDispose { + if (localInfo.idsInComposition.remove(id) && !localInfo.refCount.contains(key)) { +// if (activity?.isChangingConfigurations != true) { + entryViewModelStoreProvider.removeViewModelStoreOwnerForKey(id)?.clear() +// } + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + val childViewModelOwner = remember { + object : + ViewModelStoreOwner, + SavedStateRegistryOwner by savedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + override val viewModelStore: ViewModelStore + get() = viewModelStore + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = SavedStateViewModelFactory() + + override val defaultViewModelCreationExtras: CreationExtras + get() = + MutableCreationExtras().also { + it[SAVED_STATE_REGISTRY_OWNER_KEY] = savedStateRegistryOwner + it[VIEW_MODEL_STORE_OWNER_KEY] = this + } + + init { + require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) { + "The Lifecycle state is already beyond INITIALIZED. The " + + "ViewModelStoreNavEntryDecorator requires adding the " + + "SavedStateNavEntryDecorator to ensure support for " + + "SavedStateHandles." + } + enableSavedStateHandles() + } + } + } + CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelOwner) { + entry.content.invoke(key) + } + } +} + +private class EntryViewModel : ViewModel() { + private val owners = mutableMapOf() + val ownerInBackStack = mutableListOf() + + fun viewModelStoreForKey(key: Any): ViewModelStore = owners.getOrPut(key) { ViewModelStore() } + + fun removeViewModelStoreOwnerForKey(key: Any): ViewModelStore? = owners.remove(key) + + override fun onCleared() { + owners.forEach { (_, store) -> store.clear() } + } +} + +internal val LocalViewModelStoreNavLocalInfo = + staticCompositionLocalOf { + error( + "CompositionLocal LocalViewModelStoreNavLocalInfo not present. You must call " + + "DecorateBackStack before calling DecorateEntry." + ) + } + +internal class ViewModelStoreNavLocalInfo { + internal val refCount: MutableMap> = mutableMapOf() + @Suppress("PrimitiveInCollection") // The order of the element matters + internal val idsInComposition: LinkedHashSet = LinkedHashSet() +} + +internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count \ No newline at end of file diff --git a/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.jvm.kt b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.jvm.kt new file mode 100644 index 0000000..20d608b --- /dev/null +++ b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.jvm.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Google LLC + * + * 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.tunjid.treenav.compose + +import androidx.compose.runtime.Stable + +@Stable +internal actual val PlatformViewModelStoreNavEntryDecorator: Any? + get() = null \ No newline at end of file diff --git a/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.native.kt b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.native.kt new file mode 100644 index 0000000..20d608b --- /dev/null +++ b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.native.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Google LLC + * + * 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.tunjid.treenav.compose + +import androidx.compose.runtime.Stable + +@Stable +internal actual val PlatformViewModelStoreNavEntryDecorator: Any? + get() = null \ No newline at end of file From 78467dff6090dc17b6680b1052708817ca9ade2e Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 26 Apr 2025 10:36:32 -0400 Subject: [PATCH 05/17] Delete SlottedMultipaneDisplayScope, use navigation3 DecoratedNavEntryMultiPaneDisplayScope exclusively --- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 7 +- .../treenav/compose/MultiPaneDisplay.kt | 4 - .../compose/PanedSavableStateHolder.kt | 123 -------- .../compose/SlottedMultiPaneDisplayScope.kt | 275 ------------------ 4 files changed, 5 insertions(+), 404 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedSavableStateHolder.kt delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlottedMultiPaneDisplayScope.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index aa7ce89..361d9d2 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -65,13 +65,16 @@ internal fun DecoratedNavEntr state: MultiPaneDisplayState, content: @Composable (MultiPaneDisplayScope.() -> Unit), ) { + val navigationState by state.navigationState val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> - state.backStackTransform(state.navigationState.value).let { currentBackStack -> + state.backStackTransform(navigationState).let { currentBackStack -> mutableBackStack.clear() mutableBackStack.addAll(currentBackStack) } } - val panesToNodes = state.panesToDestinationsTransform(state.currentDestination.value) + val panesToNodes = state.panesToDestinationsTransform( + state.destinationTransform(navigationState) + ) DecoratedNavEntryProvider( backStack = backStack, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 239b154..0a48690 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -79,10 +79,6 @@ fun MultiPaneDisplay( Box( modifier = modifier ) { -// SlottedMultiPaneDisplayScope( -// state = state, -// content = content -// ) DecoratedNavEntryMultiPaneDisplayScope( state = state, content = content diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedSavableStateHolder.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedSavableStateHolder.kt deleted file mode 100644 index 1c24c97..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedSavableStateHolder.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * 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.tunjid.treenav.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.ReusableContent -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.LocalSaveableStateRegistry -import androidx.compose.runtime.saveable.SaveableStateHolder -import androidx.compose.runtime.saveable.SaveableStateRegistry -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable - -@Composable -internal fun rememberPanedSaveableStateHolder(): SaveableStateHolder = - rememberSaveable( - saver = PanedSavableStateHolder.Saver - ) { - PanedSavableStateHolder() - }.apply { - parentSaveableStateRegistry = LocalSaveableStateRegistry.current - } - -private class PanedSavableStateHolder( - private val savedStates: MutableMap>> = mutableStateMapOf(), -) : SaveableStateHolder { - private val registryHolders = mutableStateMapOf() - var parentSaveableStateRegistry: SaveableStateRegistry? = null - - @Composable - override fun SaveableStateProvider(key: Any, content: @Composable () -> Unit) { - ReusableContent(key) { - val registryHolder = remember { - require(parentSaveableStateRegistry?.canBeSaved(key) ?: true) { - "Type of the key $key is not supported. On Android you can only use types " + - "which can be stored inside the Bundle." - } - // With multiple panes co-existing, its possible for an existing destination - // to have a new registryHolder created in this remember block as it enters - // a new pane before onDispose is called in the DisposableEffect of the old pane, - // yet somehow before the DisposableEffect block that - // calls 'require(key !in registryHolders)' called. - - // This makes sure that state is saved a little earlier so the incoming block - // sees saved state. - registryHolders[key]?.saveTo(savedStates) - RegistryHolder(key) - } - CompositionLocalProvider( - LocalSaveableStateRegistry provides registryHolder.registry, - content = content, - ) - DisposableEffect(Unit) { - require(key !in registryHolders) { "Key $key was used multiple times " } - savedStates -= key - registryHolders[key] = registryHolder - onDispose { - registryHolder.saveTo(savedStates) - registryHolders -= key - } - } - } - } - - private fun saveAll(): MutableMap>>? { - val map = savedStates.toMutableMap() - registryHolders.values.forEach { it.saveTo(map) } - return map.ifEmpty { null } - } - - override fun removeState(key: Any) { - val registryHolder = registryHolders[key] - if (registryHolder != null) { - registryHolder.shouldSave = false - } else { - savedStates -= key - } - } - - inner class RegistryHolder( - val key: Any, - ) { - var shouldSave = true - val registry: SaveableStateRegistry = SaveableStateRegistry(savedStates[key]?.toMap()) { - parentSaveableStateRegistry?.canBeSaved(it) ?: true - } - - fun saveTo(map: MutableMap>>) { - if (shouldSave) { - val savedData = registry.performSave() - if (savedData.isEmpty()) { - map -= key - } else { - map[key] = savedData - } - } - } - } - - companion object { - val Saver: Saver = Saver( - save = { it.saveAll() }, - restore = { PanedSavableStateHolder(it) } - ) - } -} diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlottedMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlottedMultiPaneDisplayScope.kt deleted file mode 100644 index 2cf1464..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlottedMultiPaneDisplayScope.kt +++ /dev/null @@ -1,275 +0,0 @@ -package com.tunjid.treenav.compose - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.updateTransition -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.SaveableStateHolder -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.currentStateAsState -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.lifecycle.DestinationViewModelStoreCreator -import com.tunjid.treenav.compose.lifecycle.rememberDestinationLifecycleOwner - -@Composable -internal fun SlottedMultiPaneDisplayScope( - state: MultiPaneDisplayState, - content: @Composable (MultiPaneDisplayScope.() -> Unit), -) { - val backStack by remember { - derivedStateOf { - state.backStackTransform(state.navigationState.value) - } - } - val panesToNodes = state.panesToDestinationsTransform(state.currentDestination.value) - val saveableStateHolder = rememberPanedSaveableStateHolder() - val displayScope = remember { - SlottedMultiPaneDisplayScope( - panes = state.panes, - initialBackStack = backStack, - initialPanesToNodes = panesToNodes, - saveableStateHolder = saveableStateHolder, - paneRenderer = { - val currentDestination = remember(paneState.currentDestination) { - paneState.currentDestination - } - currentDestination?.let { destination -> - state.renderTransform(this, destination) - } - }, - ) - } - - DisposableEffect(backStack, panesToNodes) { - displayScope.onBackStackChanged( - backStack = backStack, - panesToNodes = panesToNodes - ) - onDispose { } - } - - displayScope.content() -} - -@Stable -private class SlottedMultiPaneDisplayScope( - panes: List, - initialBackStack: List, - initialPanesToNodes: Map, - saveableStateHolder: SaveableStateHolder, - private val paneRenderer: @Composable (PaneScope.() -> Unit), -) : MultiPaneDisplayScope, SaveableStateHolder by saveableStateHolder { - - private val slots = List( - size = panes.size, - init = ::Slot - ).toSet() - - private var panedNavigationState by mutableStateOf( - value = SlotBasedPanedNavigationState.initial(slots = slots) - .adaptTo( - slots = slots, - panesToNodes = initialPanesToNodes, - backStackIds = initialBackStack.ids(), - ) - ) - - private val destinationViewModelStoreCreator = DestinationViewModelStoreCreator( - validNodeIdsReader = { panedNavigationState.backStackIds + panedNavigationState.destinationIdsAnimatingOut } - ) - - private val slotsToRoutes = - mutableStateMapOf Unit>().also { map -> - map[null] = {} - slots.forEach { slot -> - map[slot] = movableContentOf { Render(slot) } - } - } - - /** - * Retrieves the a [ViewModelStoreOwner] for a given [destination]. All destinations - * with the same [Node.id] share the same [ViewModelStoreOwner]. - * - * The [destination] must be present in the navigation tree, otherwise an - * [IllegalStateException] will be thrown. - * - * @param destination The destination for which the [ViewModelStoreOwner] should - * be retrieved. - */ - fun viewModelStoreOwnerFor(destination: Destination): ViewModelStoreOwner = - destinationViewModelStoreCreator.viewModelStoreOwnerFor(destination) - - @Composable - override fun Destination(pane: Pane) { - val slot = panedNavigationState.slotFor(pane) - slotsToRoutes[slot]?.invoke() - } - - override fun adaptationsIn( - pane: Pane, - ): Set = panedNavigationState.adaptationsIn(pane) - - override fun destinationIn( - pane: Pane, - ): Destination? = panedNavigationState.destinationFor(pane) - - fun onBackStackChanged( - backStack: List, - panesToNodes: Map, - ) { - updateAdaptiveNavigationState { - adaptTo( - slots = slots.toSet(), - panesToNodes = panesToNodes, - backStackIds = backStack.ids() - ) - } - } - - /** - * Renders [slot] into its pane with scopes that allow for animations - * and shared elements. - */ - @Composable - private fun Render( - slot: Slot, - ) { - val paneTransition = updateTransition( - targetState = panedNavigationState.paneStateFor(slot), - label = "$slot-PaneTransition", - ) - paneTransition.AnimatedContent( - contentKey = { it.currentDestination?.id }, - transitionSpec = { - ContentTransform( - targetContentEnter = EnterTransition.None, - initialContentExit = ExitTransition.None, - sizeTransform = null, - ) - } - ) { targetPaneState -> - val scope = remember { - AnimatedPaneScope( - paneState = targetPaneState, - activeState = derivedStateOf { - val activePaneState = panedNavigationState.paneStateFor(slot) - activePaneState.currentDestination?.id == targetPaneState.currentDestination?.id - }, - animatedContentScope = this@AnimatedContent, - ) - } - - // While technically a backwards write, it stabilizes and ensures the values are - // correct at first composition - scope.paneState = targetPaneState - - val destination = targetPaneState.currentDestination - if (destination != null) { - val destinationLifecycleOwner = rememberDestinationLifecycleOwner( - destination - ) - val destinationViewModelOwner = remember(destination.id) { - destinationViewModelStoreCreator - .viewModelStoreOwnerFor(destination) - } - - CompositionLocalProvider( - LocalLifecycleOwner provides destinationLifecycleOwner, - LocalViewModelStoreOwner provides destinationViewModelOwner, - ) { - SaveableStateProvider(destination.id) { - scope.paneRenderer() - - DisposableEffect(Unit) { - onDispose { - val backstackIds = panedNavigationState.backStackIds - if (!backstackIds.contains(destination.id)) removeState( - destination.id - ) - } - } - - val hostLifecycleState by destinationLifecycleOwner.hostLifecycleState.currentStateAsState() - DisposableEffect( - hostLifecycleState, - scope.isActive, - panedNavigationState, - ) { - destinationLifecycleOwner.update( - hostLifecycleState = hostLifecycleState, - paneScope = scope, - panedNavigationState = panedNavigationState - ) - onDispose { - destinationLifecycleOwner.update( - hostLifecycleState = hostLifecycleState, - paneScope = scope, - panedNavigationState = panedNavigationState - ) - } - } - } - } - } - - // Add destination ids that are animating out - LaunchedEffect(transition.isRunning) { - if (transition.targetState == EnterExitState.PostExit) { - val destinationId = targetPaneState.currentDestination?.id - ?: return@LaunchedEffect - updateAdaptiveNavigationState { - copy(destinationIdsAnimatingOut = destinationIdsAnimatingOut + destinationId) - } - } - } - // Remove route ids that have animated out - DisposableEffect(Unit) { - onDispose { - val routeId = targetPaneState.currentDestination?.id ?: return@onDispose - updateAdaptiveNavigationState { - copy(destinationIdsAnimatingOut = destinationIdsAnimatingOut - routeId).prune() - } - targetPaneState.currentDestination?.let(destinationViewModelStoreCreator::clearStoreFor) - } - } - } - } - - private inline fun updateAdaptiveNavigationState( - block: SlotBasedPanedNavigationState.() -> SlotBasedPanedNavigationState, - ) { - panedNavigationState = panedNavigationState.block() - } - - private fun List.ids(): MutableSet = - fold(mutableSetOf()) { set, destination -> - set.add(destination.id) - set - } -} - -//fun PanedNavHostScope< -// Pane, -// Destination -// >.requireSavedStatePanedNavHostScope(): SavedStatePanedNavHostState.Companion.NavHostScope { -// check(this is SavedStatePanedNavHostState.Companion.NavHostScope) { -// "This PanedNavHostScope instance is not a SavedStatePanedNavHostScope" -// } -// return this -//} From dd0060a35eca2efef173f333100c4309695a8a65 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 26 Apr 2025 11:37:03 -0400 Subject: [PATCH 06/17] Bump other libraries --- gradle/libs.versions.toml | 16 ++++++++-------- sample/common/build.gradle.kts | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b606b46..5b7ca99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] androidGradlePlugin = "8.9.2" androidxActivity = "1.9.2" -activity-compose = "1.10.0" +activity-compose = "1.10.1" androidxAppCompat = "1.7.0" -androidxBenchmark = "1.3.3" -androidxCore = "1.15.0" -androidxCollection = "1.5.0-beta03" +androidxBenchmark = "1.3.4" +androidxCore = "1.16.0" +androidxCollection = "1.5.0" androidxCompose = "1.7.0" androidxPaging = "3.3.2" androidxSavedState = "1.3.0-alpha07" @@ -19,11 +19,10 @@ jetbrainsLifecycle = "2.9.0-alpha07" jetbrainsMaterial3Adaptive = "1.0.1" junit4 = "4.13.2" kotlin = "2.1.20" -kotlinxCoroutines = "1.9.0" -kotlinxDatetime = "0.6.1" -lifecycle-runtime = "2.8.6" +kotlinxCoroutines = "1.10.2" +kotlinxDatetime = "0.6.2" tunjidStateHolder = "1.1.0" -tunjidComposables = "0.0.14" +tunjidComposables = "0.0.16" junit = "4.13.2" runner = "1.0.2" espressoCore = "3.0.2" @@ -66,6 +65,7 @@ jetbrains-compose-foundation = { group = "org.jetbrains.compose.foundation", nam jetbrains-compose-foundation-layout = { group = "org.jetbrains.compose.foundation", name = "foundation-layout", version.ref = "jetbrainsCompose" } jetbrains-compose-gradlePlugin = { group = "org.jetbrains.compose", name = "compose-gradle-plugin", version.ref = "jetbrainsCompose" } jetbrains-compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-backhandler = { group = "org.jetbrains.compose.ui", name = "ui-backhandler", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-platform = { group = "org.jetbrains.compose.ui", name = "ui-platform", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-test = { group = "org.jetbrains.compose.ui", name = "ui-test-junit4", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-testManifest = { group = "org.jetbrains.compose.ui", name = "ui-test-manifest", version.ref = "jetbrainsCompose" } diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts index cf2d9f7..96d91db 100755 --- a/sample/common/build.gradle.kts +++ b/sample/common/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { implementation(libs.jetbrains.compose.animation) implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.foundation.layout) + implementation(libs.jetbrains.compose.ui.backhandler) implementation(libs.jetbrains.lifecycle.runtime) implementation(libs.jetbrains.lifecycle.runtime.compose) From a8866c346e1e62e1083ac2dee30062b5ecf38b68 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 08:57:15 -0400 Subject: [PATCH 07/17] Delete AnimatedBoundsModifier --- .../MovableSharedElements.kt | 17 +- .../utilities/AnimatedBoundsModifier.kt | 357 ------------------ 2 files changed, 14 insertions(+), 360 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt index 9ce21a6..acec47b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt @@ -7,6 +7,9 @@ import androidx.compose.animation.SharedTransitionScope.OverlayClip import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.contentSize import androidx.compose.animation.SharedTransitionScope.SharedContentState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -20,9 +23,8 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.PaneScope import com.tunjid.treenav.compose.MultiPaneDisplay -import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform +import com.tunjid.treenav.compose.PaneScope /** * Creates movable shared elements that may be shared amongst different [PaneScope] @@ -263,4 +265,13 @@ private val ParentClip: OverlayClip = ): Path? { return state.parentSharedContentState?.clipPathInOverlay } - } \ No newline at end of file + } + +@OptIn(ExperimentalSharedTransitionApi::class) +private val DefaultBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt deleted file mode 100644 index 373bc8b..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt +++ /dev/null @@ -1,357 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * 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.tunjid.treenav.compose.utilities - -import androidx.compose.animation.BoundsTransform -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector4D -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.spring -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.geometry.isSpecified -import androidx.compose.ui.geometry.isUnspecified -import androidx.compose.ui.layout.ApproachLayoutModifierNode -import androidx.compose.ui.layout.ApproachMeasureScope -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.LookaheadScope -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.round -import androidx.compose.ui.unit.roundToIntSize -import androidx.compose.ui.unit.toSize -import androidx.compose.ui.util.fastRoundToInt -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.launch - -@ExperimentalSharedTransitionApi // Depends on BoundsTransform -internal class AnimatedBoundsState( - val lookaheadScope: LookaheadScope, - boundsTransform: BoundsTransform = DefaultBoundsTransform, - animateMotionFrameOfReference: Boolean = false, - private val inProgress: (() -> Boolean)? = null, -) { - var targetOffset by mutableStateOf(IntOffset.Zero) - var boundsTransform by mutableStateOf(boundsTransform) - var animateMotionFrameOfReference by mutableStateOf(animateMotionFrameOfReference) - - val isInProgress get() = inProgress?.invoke() ?: !boundsAnimation.isIdle - val isIdle get() = boundsAnimation.isIdle - - private val boundsAnimation = BoundsTransformDeferredAnimation() - - companion object { - /** - * A copy of the bounds transform in the compose library that allows for reading the state - * and overriding when the approach is in progress. - */ - @ExperimentalSharedTransitionApi // Depends on BoundsTransform - internal fun Modifier.animateBounds( - state: AnimatedBoundsState, - ): Modifier = - this then BoundsAnimationElement( - state = state, - resolveMeasureConstraints = { animatedSize, _ -> - // For the target Layout, pass the animated size as Constraints. - Constraints.fixed(animatedSize.width, animatedSize.height) - }, - ) - - @ExperimentalSharedTransitionApi - internal data class BoundsAnimationElement( - val resolveMeasureConstraints: (animatedSize: IntSize, constraints: Constraints) -> Constraints, - val state: AnimatedBoundsState, - ) : ModifierNodeElement() { - override fun create(): BoundsAnimationModifierNode { - return BoundsAnimationModifierNode( - state = state, - onChooseMeasureConstraints = resolveMeasureConstraints, - ) - } - - override fun update(node: BoundsAnimationModifierNode) { - node.onChooseMeasureConstraints = resolveMeasureConstraints - } - - override fun InspectorInfo.inspectableProperties() { - name = "boundsAnimation" - properties["onChooseMeasureConstraints"] = resolveMeasureConstraints - properties["state"] = state - } - } - - @ExperimentalSharedTransitionApi - internal class BoundsAnimationModifierNode( - var onChooseMeasureConstraints: - (animatedSize: IntSize, constraints: Constraints) -> Constraints, - val state: AnimatedBoundsState, - ) : ApproachLayoutModifierNode, Modifier.Node() { - - override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { - // Update target size, it will serve to know if we expect an approach in progress - state.boundsAnimation.updateTargetSize(lookaheadSize.toSize()) - - return state.isInProgress - } - - override fun Placeable.PlacementScope.isPlacementApproachInProgress( - lookaheadCoordinates: LayoutCoordinates - ): Boolean { - // Once we can capture size and offset we may also start the animation - state.boundsAnimation.updateTargetOffsetAndAnimate( - lookaheadScope = state.lookaheadScope, - placementScope = this, - coroutineScope = coroutineScope, - includeMotionFrameOfReference = state.animateMotionFrameOfReference, - boundsTransform = state.boundsTransform, - ) - return state.isInProgress - } - - override fun ApproachMeasureScope.approachMeasure( - measurable: Measurable, - constraints: Constraints - ): MeasureResult { - // The animated value is null on the first frame as we don't get the full bounds - // information until placement, so we can safely use the current Size. - val fallbackSize = - if (state.boundsAnimation.currentSize.isUnspecified) { - // When using Intrinsics, we may get measured before getting the approach check - lookaheadSize.toSize() - } else { - state.boundsAnimation.currentSize - } - val animatedSize = - (state.boundsAnimation.value?.size ?: fallbackSize).roundToIntSize() - - val chosenConstraints = onChooseMeasureConstraints(animatedSize, constraints) - - val placeable = measurable.measure(chosenConstraints) - - return layout(animatedSize.width, animatedSize.height) { - val animatedBounds = state.boundsAnimation.value - val positionInScope = - with(state.lookaheadScope) { - coordinates?.let { coordinates -> - lookaheadScopeCoordinates.localPositionOf( - sourceCoordinates = coordinates, - relativeToSource = Offset.Zero, - includeMotionFrameOfReference = state.animateMotionFrameOfReference - ) - } - } - - val topLeft = - if (animatedBounds != null) { - state.boundsAnimation.updateCurrentBounds( - animatedBounds.topLeft, - animatedBounds.size - ) - animatedBounds.topLeft - } else { - state.boundsAnimation.currentBounds?.topLeft ?: Offset.Zero - } - state.targetOffset = topLeft.round() - val (x, y) = positionInScope?.let { topLeft - it } ?: Offset.Zero - placeable.place(x.fastRoundToInt(), y.fastRoundToInt()) - } - } - } - } -} - -/** Helper class to keep track of the BoundsAnimation state for [ApproachLayoutModifierNode]. */ -@OptIn(ExperimentalSharedTransitionApi::class) -internal class BoundsTransformDeferredAnimation { - private var animatable: Animatable? = null - - private var targetSize: Size = Size.Unspecified - private var targetOffset: Offset = Offset.Unspecified - - private var isPending = false - - /** - * Captures lookahead size, updates current size for the first pass and marks the animation as - * pending. - */ - fun updateTargetSize(size: Size) { - if (targetSize.isSpecified && size.roundToIntSize() != targetSize.roundToIntSize()) { - // Change in target, animation is pending - isPending = true - } - targetSize = size - - if (currentSize.isUnspecified) { - currentSize = size - } - } - - /** - * Captures lookahead position, updates current position for the first pass and marks the - * animation as pending. - */ - private fun updateTargetOffset(offset: Offset) { - if (targetOffset.isSpecified && offset.round() != targetOffset.round()) { - isPending = true - } - targetOffset = offset - - if (currentPosition.isUnspecified) { - currentPosition = offset - } - } - - // We capture the current bounds parameters individually to avoid unnecessary Rect allocations - private var currentPosition: Offset = Offset.Unspecified - var currentSize: Size = Size.Unspecified - - val currentBounds: Rect? - get() { - val size = currentSize - val position = currentPosition - return if (position.isSpecified && size.isSpecified) { - Rect(position, size) - } else { - null - } - } - - fun updateCurrentBounds(position: Offset, size: Size) { - currentPosition = position - currentSize = size - } - - val isIdle: Boolean - get() = !isPending && animatable?.isRunning != true - - private var animatedValue: Rect? by mutableStateOf(null) - - val value: Rect? - get() = if (isIdle) null else animatedValue - - private var directManipulationParents: MutableList? = null - private var additionalOffset: Offset = Offset.Zero - - fun updateTargetOffsetAndAnimate( - lookaheadScope: LookaheadScope, - placementScope: Placeable.PlacementScope, - coroutineScope: CoroutineScope, - includeMotionFrameOfReference: Boolean, - boundsTransform: BoundsTransform, - ) { - placementScope.coordinates?.let { coordinates -> - with(lookaheadScope) { - val lookaheadScopeCoordinates = placementScope.lookaheadScopeCoordinates - - var delta = Offset.Zero - if (!includeMotionFrameOfReference) { - // As the Layout changes, we need to keep track of the accumulated offset up - // the hierarchy tree, to get the proper Offset accounting for scrolling. - val parents = directManipulationParents ?: mutableListOf() - var currentCoords = coordinates - var index = 0 - - // Find the given lookahead coordinates by traversing up the tree - while (currentCoords.toLookaheadCoordinates() != lookaheadScopeCoordinates) { - if (currentCoords.isAttached && currentCoords.introducesMotionFrameOfReference) { - if (parents.size == index) { - parents.add(currentCoords) - delta += currentCoords.positionInParent() - } else if (parents[index] != currentCoords) { - delta -= parents[index].positionInParent() - parents[index] = currentCoords - delta += currentCoords.positionInParent() - } - index++ - } - currentCoords = currentCoords.parentCoordinates - ?.takeIf(LayoutCoordinates::isAttached) - ?: break - } - - for (i in parents.size - 1 downTo index) { - if (parents[i].isAttached) delta -= parents[i].positionInParent() - parents.removeAt(parents.size - 1) - } - directManipulationParents = parents - } - additionalOffset += delta - - val targetOffset = - lookaheadScopeCoordinates.localLookaheadPositionOf( - sourceCoordinates = coordinates, - includeMotionFrameOfReference = includeMotionFrameOfReference - ) - updateTargetOffset(targetOffset + additionalOffset) - - animatedValue = - animate(coroutineScope = coroutineScope, boundsTransform = boundsTransform) - .translate(-(additionalOffset)) - } - } - } - - private fun animate( - coroutineScope: CoroutineScope, - boundsTransform: BoundsTransform, - ): Rect { - if (targetOffset.isSpecified && targetSize.isSpecified) { - // Initialize Animatable when possible, we might not use it but we need to have it - // instantiated since at the first pass the lookahead information will become the - // initial bounds when we actually need an animation. - val target = Rect(targetOffset, targetSize) - val anim = animatable ?: Animatable(target, Rect.VectorConverter) - animatable = anim - - // This check should avoid triggering an animation on the first pass, as there would not - // be enough information to have a distinct current and target bounds. - if (isPending) { - isPending = false - coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { - // Dispatch right away to make sure approach callbacks are accurate on `isIdle` - anim.animateTo(target, boundsTransform.transform(currentBounds!!, target)) - } - } - } - return animatable?.value ?: Rect.Zero - } -} - -@OptIn(ExperimentalSharedTransitionApi::class) -internal val DefaultBoundsTransform = BoundsTransform { _, _ -> - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = Rect.VisibilityThreshold - ) -} \ No newline at end of file From ba484e91691e5849e921c0de658466d557a04f3e Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 11:00:09 -0400 Subject: [PATCH 08/17] Replace panesToNodes with panesToDestinations --- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 16 ++++++++-------- .../compose/SlotBasedPanedNavigationState.kt | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index 361d9d2..5988d17 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -72,7 +72,7 @@ internal fun DecoratedNavEntr mutableBackStack.addAll(currentBackStack) } } - val panesToNodes = state.panesToDestinationsTransform( + val panesToDestinations = state.panesToDestinationsTransform( state.destinationTransform(navigationState) ) @@ -99,7 +99,7 @@ internal fun DecoratedNavEntr Navigation3MultiPaneDisplayScope( panes = state.panes, initialBackStack = backStack, - initialPanesToNodes = panesToNodes, + initialPanesToDestinations = panesToDestinations, paneRenderer = { val currentEntry = remember(paneState.currentDestination?.id) { updatedEntries.findLast { @@ -112,10 +112,10 @@ internal fun DecoratedNavEntr }, ) } - DisposableEffect(backStack, panesToNodes) { + DisposableEffect(backStack, panesToDestinations) { displayScope.onBackStackChanged( backStack = backStack, - panesToNodes = panesToNodes + panesToDestinations = panesToDestinations ) onDispose { } } @@ -129,7 +129,7 @@ internal fun DecoratedNavEntr private class Navigation3MultiPaneDisplayScope( panes: List, initialBackStack: List, - initialPanesToNodes: Map, + initialPanesToDestinations: Map, private val paneRenderer: @Composable (PaneScope.() -> Unit), ) : MultiPaneDisplayScope { @@ -142,7 +142,7 @@ private class Navigation3MultiPaneDisplayScope( value = SlotBasedPanedNavigationState.initial(slots = slots) .adaptTo( slots = slots, - panesToNodes = initialPanesToNodes, + panesToDestinations = initialPanesToDestinations, backStackIds = initialBackStack.ids(), ) ) @@ -171,12 +171,12 @@ private class Navigation3MultiPaneDisplayScope( fun onBackStackChanged( backStack: List, - panesToNodes: Map, + panesToDestinations: Map, ) { updateAdaptiveNavigationState { adaptTo( slots = slots.toSet(), - panesToNodes = panesToNodes, + panesToDestinations = panesToDestinations, backStackIds = backStack.ids() ) } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt index 471ed00..13d4ce8 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt @@ -123,7 +123,7 @@ internal data class SlotBasedPanedNavigationState( */ internal fun SlotBasedPanedNavigationState.adaptTo( slots: Set, - panesToNodes: Map, + panesToDestinations: Map, backStackIds: Set, ): SlotBasedPanedNavigationState { val previous = this @@ -139,13 +139,13 @@ internal fun SlotBasedPanedNavigationState.adaptTo( .sortedByDescending(previouslyUsedSlots::contains) .toMutableSet() - val unplacedNodeIds = panesToNodes.values.mapNotNull { it?.id }.toMutableSet() + val unplacedNodeIds = panesToDestinations.values.mapNotNull { it?.id }.toMutableSet() val nodeIdsToAdaptiveSlots = mutableMapOf() val swapAdaptations = mutableSetOf>() // Process nodes that swapped panes from old to new - for ((toPane, toNode) in panesToNodes.entries) { + for ((toPane, toNode) in panesToDestinations.entries) { if (toNode == null) continue for ((fromPane, fromNode) in previous.panesToDestinations.entries) { // Find a previous node from the last state @@ -181,7 +181,7 @@ internal fun SlotBasedPanedNavigationState.adaptTo( return SlotBasedPanedNavigationState( // If the values of the nodes to panes are the same, no swaps occurred. swapAdaptations = when (previous.panesToDestinations.mapValues { it.value?.id }) { - panesToNodes.mapValues { it.value?.id } -> previous.swapAdaptations + panesToDestinations.mapValues { it.value?.id } -> previous.swapAdaptations else -> swapAdaptations }, previousPanesToDestinations = previous.panesToDestinations.keys.associateWith( @@ -189,7 +189,7 @@ internal fun SlotBasedPanedNavigationState.adaptTo( ), destinationIdsToAdaptiveSlots = nodeIdsToAdaptiveSlots, backStackIds = backStackIds, - panesToDestinations = panesToNodes, + panesToDestinations = panesToDestinations, destinationIdsAnimatingOut = previous.destinationIdsAnimatingOut, ) From 5fa04d1cce5a5e93725698f8cb7e81f06ed06785 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 11:04:50 -0400 Subject: [PATCH 09/17] Remove unused currentDestination derived val --- .../com/tunjid/treenav/compose/MultiPaneDisplayState.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 072c299..2fa3a0e 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -47,11 +47,7 @@ class MultiPaneDisplayState in val destinationTransform: (NavigationState) -> Destination, val panesToDestinationsTransform: @Composable (Destination) -> Map, val renderTransform: @Composable PaneScope.(Destination) -> Unit, -) { - internal val currentDestination: State = derivedStateOf { - destinationTransform(navigationState.value) - } -} +) /** * Provides an [MultiPaneDisplayState] for configuring a [MultiPaneDisplay] for From dad4a73ef433b43f803cc1b39e843c014fe8b783 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 11:06:09 -0400 Subject: [PATCH 10/17] Renamed Navigation3MultiPaneDisplayScope to DecoratedNavEntryMultiPaneDisplayScope --- .../treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index 5988d17..41eaaff 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -96,7 +96,7 @@ internal fun DecoratedNavEntr content = { entries -> val updatedEntries by rememberUpdatedState(entries) val displayScope = remember { - Navigation3MultiPaneDisplayScope( + DecoratedNavEntryMultiPaneDisplayScope( panes = state.panes, initialBackStack = backStack, initialPanesToDestinations = panesToDestinations, @@ -126,7 +126,7 @@ internal fun DecoratedNavEntr } @Stable -private class Navigation3MultiPaneDisplayScope( +private class DecoratedNavEntryMultiPaneDisplayScope( panes: List, initialBackStack: List, initialPanesToDestinations: Map, From fdbb9ef9a0fd81b7f23890514d0827a893bc54c2 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 17:06:06 -0400 Subject: [PATCH 11/17] Add Pop adaptation to PaneState with tests --- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 20 ++- .../treenav/compose/PanedNavigationState.kt | 6 + .../compose/SlotBasedPanedNavigationState.kt | 23 +++- .../SlotBasedAdaptiveNavigationStateTest.kt | 119 ++++++++++++++---- .../com/tunjid/treenav/MultiStackNav.kt | 35 +++++- .../kotlin/com/tunjid/treenav/StackNav.kt | 37 +++++- .../commonTest/kotlin/MultiStackNavTest.kt | 46 ++++++- .../src/commonTest/kotlin/StackNavTest.kt | 48 ++++++- .../com/tunjid/demo/common/ui/DemoApp.kt | 1 - 9 files changed, 285 insertions(+), 50 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index 41eaaff..e286dcd 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -112,9 +112,9 @@ internal fun DecoratedNavEntr }, ) } - DisposableEffect(backStack, panesToDestinations) { + DisposableEffect(navigationState, panesToDestinations) { displayScope.onBackStackChanged( - backStack = backStack, + backStackIds = backStack.map { it.id }, panesToDestinations = panesToDestinations ) onDispose { } @@ -143,7 +143,7 @@ private class DecoratedNavEntryMultiPaneDisplayScope( .adaptTo( slots = slots, panesToDestinations = initialPanesToDestinations, - backStackIds = initialBackStack.ids(), + backStackIds = initialBackStack.map { it.id }, ) ) @@ -170,14 +170,14 @@ private class DecoratedNavEntryMultiPaneDisplayScope( ): Destination? = panedNavigationState.destinationFor(pane) fun onBackStackChanged( - backStack: List, + backStackIds: List, panesToDestinations: Map, ) { updateAdaptiveNavigationState { adaptTo( slots = slots.toSet(), panesToDestinations = panesToDestinations, - backStackIds = backStack.ids() + backStackIds = backStackIds, ) } } @@ -235,16 +235,12 @@ private class DecoratedNavEntryMultiPaneDisplayScope( ) { panedNavigationState = panedNavigationState.block() } - - private fun List.ids(): MutableSet = - fold(mutableSetOf()) { set, destination -> - set.add(destination.id) - set - } } private val LocalPaneScope = staticCompositionLocalOf> { - TODO() + throw IllegalArgumentException( + "PaneScope should not be read until provided in the composition" + ) } @Stable diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt index a41bef7..cd11573 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt @@ -16,6 +16,12 @@ sealed class Adaptation { */ data object Change : Adaptation() + /** + * The current navigation state is considered identical + * to that preceding the penultimate navigation state. + */ + data object Pop : Adaptation() + /** * Destinations were swapped in between panes */ diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt index 13d4ce8..3208ce5 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt @@ -25,6 +25,10 @@ import com.tunjid.treenav.compose.Adaptation.Change.contains */ @Immutable internal data class SlotBasedPanedNavigationState( + /** + * True if this navigation change is as a result of popping the backStack. + */ + val isPop: Boolean, /** * Moves between panes within a navigation sequence. */ @@ -44,7 +48,7 @@ internal data class SlotBasedPanedNavigationState( /** * A set of node ids that may be returned to. */ - val backStackIds: Set, + val backStackIds: List, /** * A set of node ids that are animating out. */ @@ -54,12 +58,13 @@ internal data class SlotBasedPanedNavigationState( internal fun initial( slots: Collection, ): SlotBasedPanedNavigationState = SlotBasedPanedNavigationState( + isPop = false, swapAdaptations = emptySet(), panesToDestinations = emptyMap(), destinationIdsToAdaptiveSlots = slots.associateBy( keySelector = Slot::toString ), - backStackIds = emptySet(), + backStackIds = emptyList(), destinationIdsAnimatingOut = emptySet(), previousPanesToDestinations = emptyMap(), ) @@ -109,11 +114,12 @@ internal data class SlotBasedPanedNavigationState( pane: Pane, ): Set { val swaps = swapAdaptations.filter { pane in it } - return if (swaps.isEmpty()) when (panesToDestinations[pane]?.id) { + val adaptations = if (swaps.isEmpty()) when (panesToDestinations[pane]?.id) { previousPanesToDestinations[pane]?.id -> setOf(Adaptation.Same) else -> setOf(Adaptation.Change) } else swaps.toSet() + return if (isPop) adaptations + Adaptation.Pop else adaptations } } @@ -124,7 +130,7 @@ internal data class SlotBasedPanedNavigationState( internal fun SlotBasedPanedNavigationState.adaptTo( slots: Set, panesToDestinations: Map, - backStackIds: Set, + backStackIds: List, ): SlotBasedPanedNavigationState { val previous = this @@ -179,6 +185,15 @@ internal fun SlotBasedPanedNavigationState.adaptTo( } return SlotBasedPanedNavigationState( + backStackIds.let popCheck@{ ids -> + if (ids.size >= previous.backStackIds.size) return@popCheck false + if (ids.isEmpty()) return@popCheck true + + for (index in ids.indices) { + if (ids[index] != previous.backStackIds[index]) return@popCheck false + } + true + }, // If the values of the nodes to panes are the same, no swaps occurred. swapAdaptations = when (previous.panesToDestinations.mapValues { it.value?.id }) { panesToDestinations.mapValues { it.value?.id } -> previous.swapAdaptations diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt index f2236f7..f59d720 100644 --- a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt @@ -17,10 +17,17 @@ package com.tunjid.treenav.compose import com.tunjid.treenav.Node +import com.tunjid.treenav.StackNav +import com.tunjid.treenav.backStack import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.current +import com.tunjid.treenav.pop +import com.tunjid.treenav.push import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /* * Copyright 2021 Google LLC @@ -38,7 +45,10 @@ import kotlin.test.assertEquals * limitations under the License. */ -data class TestNode(val name: String) : Node { +data class TestNode( + val name: String, + override val children: List = emptyList(), +) : Node { override val id: String get() = name } @@ -61,7 +71,8 @@ class SlotBasedAdaptiveNavigationStateTest { @Test fun testFirstSinglePaneAdaptation() { subject.testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ) ) @@ -85,7 +96,8 @@ class SlotBasedAdaptiveNavigationStateTest { fun testFirstTriplePaneAdaptation() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ThreePane.Secondary to TestNode(name = "B"), ThreePane.Tertiary to TestNode(name = "C"), @@ -140,12 +152,14 @@ class SlotBasedAdaptiveNavigationStateTest { fun testSameAdaptationInSinglePane() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ) ) .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ) ) @@ -169,14 +183,16 @@ class SlotBasedAdaptiveNavigationStateTest { fun testSameAdaptationInThreePanes() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ThreePane.Secondary to TestNode(name = "B"), ThreePane.Tertiary to TestNode(name = "C"), ) ) .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ThreePane.Secondary to TestNode(name = "B"), ThreePane.Tertiary to TestNode(name = "C"), @@ -231,14 +247,16 @@ class SlotBasedAdaptiveNavigationStateTest { fun testChangeAdaptationInThreePanes() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ThreePane.Secondary to TestNode(name = "B"), ThreePane.Tertiary to TestNode(name = "C"), ) ) .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "B"), ThreePane.Secondary to TestNode(name = "C"), ThreePane.Tertiary to TestNode(name = "A"), @@ -302,12 +320,14 @@ class SlotBasedAdaptiveNavigationStateTest { fun testListToListDetailAdaptation() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ) ) .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "B"), ThreePane.Secondary to TestNode(name = "A"), ) @@ -352,7 +372,8 @@ class SlotBasedAdaptiveNavigationStateTest { fun testSinglePanePredictiveBackAdaptation() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ) ) @@ -363,7 +384,8 @@ class SlotBasedAdaptiveNavigationStateTest { ) } .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "B"), ) ) @@ -374,7 +396,8 @@ class SlotBasedAdaptiveNavigationStateTest { ) } .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ThreePane.TransientPrimary to TestNode(name = "B"), ) @@ -416,13 +439,15 @@ class SlotBasedAdaptiveNavigationStateTest { fun testDoublePaneToSinglePanePredictiveBackAdaptation() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ThreePane.Secondary to TestNode(name = "B"), ) ) .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "C"), ThreePane.TransientPrimary to TestNode(name = "A"), ) @@ -470,13 +495,15 @@ class SlotBasedAdaptiveNavigationStateTest { fun testDoublePaneToDoublePanePredictiveBackAdaptation() { subject .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "A"), ThreePane.Secondary to TestNode(name = "B"), ) ) .testAdaptTo( - panesToNodes = mapOf( + navState = EmptyNavState, + panesToDestinations = mapOf( ThreePane.Primary to TestNode(name = "C"), ThreePane.Secondary to TestNode(name = "D"), ThreePane.TransientPrimary to TestNode(name = "A"), @@ -531,12 +558,62 @@ class SlotBasedAdaptiveNavigationStateTest { } } + @Test + fun testIsPop() { + val navStates = (1..3).fold(listOf(StartNavState)) { navStateList, index -> + navStateList + navStateList.last().push(TestNode(index.toString())) + } + + subject = navStates.indices + .fold(subject) { foldedSubject, index -> + foldedSubject + .testAdaptTo( + navState = navStates[index], + panesToDestinations = mapOf( + ThreePane.Primary to navStates[index].current(), + ) + ) + .apply { + assertFalse(isPop) + } + } + + val poppedNavStates = navStates.map { it.pop(popLast = true) } + + poppedNavStates.indices + .reversed() + .fold(subject) { foldedSubject, index -> + foldedSubject + .testAdaptTo( + navState = poppedNavStates[index], + panesToDestinations = mapOf( + ThreePane.Primary to navStates[index].current(), + ) + ) + .apply { + assertTrue(isPop) + } + } + } + private fun SlotBasedPanedNavigationState.testAdaptTo( - panesToNodes: Map + navState: StackNav, + panesToDestinations: Map, ) = adaptTo( slots = slots, - backStackIds = emptySet(), - panesToNodes = panesToNodes + backStackIds = navState.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .filterIsInstance() + .map { it.id }, + panesToDestinations = panesToDestinations ) } +private val EmptyNavState = StackNav( + name = "TestNavState", + children = emptyList(), +) + +private val StartNavState = EmptyNavState.push(TestNode("0")) \ No newline at end of file diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt index 2998830..76489ed 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt @@ -80,15 +80,17 @@ fun MultiStackNav.popToRoot(indexToPop: Int = currentIndex) = copy( ) /** - * Returns a sequence of each destination on the back stack for this [StackNav] as defined by + * Returns a sequence of each destination on the back stack for this [MultiStackNav] as defined by * [MultiStackNav.pop]. * + * Note that this sequence is reversed; i.e the first item is the [Node] on top of the stack. + * * @param includeCurrentDestinationChildren when true, the result of [Node.children] for each * [Node] is included in the back stack. * @param placeChildrenBeforeParent when true, the result of [Node.children] are paced before * the parent [Node] in the back stack. */ -fun MultiStackNav.backStack( +fun MultiStackNav.reversedBackStackSequence( includeCurrentDestinationChildren: Boolean, placeChildrenBeforeParent: Boolean = false, ): Sequence = @@ -109,6 +111,27 @@ fun MultiStackNav.backStack( else parent + children } +/** + * Returns a [List] representing the back stack for this [MultiStackNav] as defined by + * [MultiStackNav.pop]. + * + * @see [MultiStackNav.reversedBackStackSequence] + * + * @param includeCurrentDestinationChildren when true, the result of [Node.children] for each + * [Node] is included in the back stack. + * @param placeChildrenBeforeParent when true, the result of [Node.children] are paced before + * the parent [Node] in the back stack. + */ +fun MultiStackNav.backStack( + includeCurrentDestinationChildren: Boolean, + placeChildrenBeforeParent: Boolean = false, +): List = reversedBackStackSequence( + includeCurrentDestinationChildren = includeCurrentDestinationChildren, + placeChildrenBeforeParent = !placeChildrenBeforeParent +) + .toList() + .asReversed() + /** * Performs the given [operation] with the [StackNav] at [MultiStackNav.currentIndex] */ @@ -120,3 +143,11 @@ private inline fun MultiStackNav.atCurrentIndex(operation: StackNav.() -> StackN ) val MultiStackNav.current: Node? get() = stacks.getOrNull(currentIndex)?.children?.lastOrNull() + +inline fun MultiStackNav.current(): T? { + val node = current ?: return null + check(node is T) { + "Expected the current node to be of type ${T::class.qualifiedName} but was ${node::class.qualifiedName}." + } + return node +} \ No newline at end of file diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt index 19a4a20..a69fc67 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt @@ -59,12 +59,14 @@ fun StackNav.popToRoot() = copy( * Returns a sequence of each destination on the back stack for this [StackNav] as defined by * [StackNav.pop]. * + * Note that this sequence is reversed; i.e the first item is the [Node] on top of the stack. + * * @param includeCurrentDestinationChildren when true, the result of [Node.children] for each * [Node] is included in the back stack. * @param placeChildrenBeforeParent when true, the result of [Node.children] are paced before * the parent [Node] in the back stack. */ -fun StackNav.backStack( +fun StackNav.reversedBackStackSequence( includeCurrentDestinationChildren: Boolean, placeChildrenBeforeParent: Boolean = false, ): Sequence = @@ -85,9 +87,38 @@ fun StackNav.backStack( else parent + children } +/** + * Returns a [List] representing the back stack for this [StackNav] as defined by + * [StackNav.pop]. + * + * @see [StackNav.reversedBackStackSequence] + * + * @param includeCurrentDestinationChildren when true, the result of [Node.children] for each + * [Node] is included in the back stack. + * @param placeChildrenBeforeParent when true, the result of [Node.children] are paced before + * the parent [Node] in the back stack. + */ +fun StackNav.backStack( + includeCurrentDestinationChildren: Boolean, + placeChildrenBeforeParent: Boolean = false, +): List = reversedBackStackSequence( + includeCurrentDestinationChildren = includeCurrentDestinationChildren, + placeChildrenBeforeParent = !placeChildrenBeforeParent +) + .toList() + .asReversed() + /** * Indicates if there's a [Node] available to pop up to */ -val StackNav.canPop get() = children.size > 1 +val StackNav.canPop: Boolean get() = children.size > 1 + +val StackNav.current: Node? get() = children.lastOrNull() -val StackNav.current get() = children.lastOrNull() +inline fun StackNav.current(): T? { + val node = current ?: return null + check(node is T) { + "Expected the current node to be of type ${T::class.qualifiedName} but was ${node::class.qualifiedName}." + } + return node +} diff --git a/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt b/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt index c279453..6322686 100644 --- a/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt +++ b/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt @@ -18,6 +18,7 @@ import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.Order import com.tunjid.treenav.StackNav import com.tunjid.treenav.backStack +import com.tunjid.treenav.reversedBackStackSequence import com.tunjid.treenav.flatten import com.tunjid.treenav.minus import com.tunjid.treenav.pop @@ -340,13 +341,26 @@ class MultiStackNavTest { TestNode("B"), TestNode("A", children = listOf(TestNode("1"))), ), - pushed.backStack( + pushed.reversedBackStackSequence( includeCurrentDestinationChildren = false, placeChildrenBeforeParent = false, ) .toList() ) + assertEquals( + expected = pushed.reversedBackStackSequence( + includeCurrentDestinationChildren = false, + placeChildrenBeforeParent = false, + ) + .toList(), + actual = pushed.backStack( + includeCurrentDestinationChildren = false, + placeChildrenBeforeParent = true, + ) + .asReversed() + ) + assertEquals( expected = listOf( TestNode("F"), @@ -359,13 +373,26 @@ class MultiStackNavTest { TestNode("A", children = listOf(TestNode("1"))), TestNode("1"), ), - actual = pushed.backStack( + actual = pushed.reversedBackStackSequence( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = false, ) .toList() ) + assertEquals( + expected = pushed.reversedBackStackSequence( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = false, + ) + .toList(), + actual = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .asReversed() + ) + assertEquals( expected = listOf( TestNode("F"), @@ -378,11 +405,24 @@ class MultiStackNavTest { TestNode("1"), TestNode("A", children = listOf(TestNode("1"))), ), - actual = pushed.backStack( + actual = pushed.reversedBackStackSequence( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, ) .toList() ) + + assertEquals( + expected = pushed.reversedBackStackSequence( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .toList(), + actual = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = false, + ) + .asReversed() + ) } } diff --git a/library/treenav/src/commonTest/kotlin/StackNavTest.kt b/library/treenav/src/commonTest/kotlin/StackNavTest.kt index 09043e0..a0527f9 100644 --- a/library/treenav/src/commonTest/kotlin/StackNavTest.kt +++ b/library/treenav/src/commonTest/kotlin/StackNavTest.kt @@ -18,6 +18,7 @@ import com.tunjid.treenav.Node import com.tunjid.treenav.Order import com.tunjid.treenav.StackNav import com.tunjid.treenav.backStack +import com.tunjid.treenav.reversedBackStackSequence import com.tunjid.treenav.current import com.tunjid.treenav.flatten import com.tunjid.treenav.minus @@ -158,7 +159,7 @@ class StackNavTest { } @Test - fun testBackStack() { + fun testReversedBackStack() { val pushed = subject .push(TestNode("A", children = listOf(TestNode("1")))) .push(TestNode("B")) @@ -176,13 +177,26 @@ class StackNavTest { TestNode("B"), TestNode("A", children = listOf(TestNode("1"))), ), - pushed.backStack( + pushed.reversedBackStackSequence( includeCurrentDestinationChildren = false, placeChildrenBeforeParent = false, ) .toList() ) + assertEquals( + pushed.reversedBackStackSequence( + includeCurrentDestinationChildren = false, + placeChildrenBeforeParent = false, + ) + .toList(), + pushed.backStack( + includeCurrentDestinationChildren = false, + placeChildrenBeforeParent = true, + ) + .asReversed() + ) + assertEquals( expected = listOf( TestNode("F"), @@ -195,13 +209,26 @@ class StackNavTest { TestNode("A", children = listOf(TestNode("1"))), TestNode("1"), ), - actual = pushed.backStack( + actual = pushed.reversedBackStackSequence( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = false, ) .toList() ) + assertEquals( + pushed.reversedBackStackSequence( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = false, + ) + .toList(), + pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .asReversed() + ) + assertEquals( expected = listOf( TestNode("F"), @@ -214,11 +241,24 @@ class StackNavTest { TestNode("1"), TestNode("A", children = listOf(TestNode("1"))), ), - actual = pushed.backStack( + actual = pushed.reversedBackStackSequence( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, ) .toList() ) + + assertEquals( + pushed.reversedBackStackSequence( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .toList(), + pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = false, + ) + .asReversed() + ) } } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt index c536f14..920b33e 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt @@ -344,7 +344,6 @@ class AppState( placeChildrenBeforeParent = true, ) .filterIsInstance() - .toList() }, destinationTransform = { it.current as? SampleDestination ?: throw IllegalArgumentException( From 6c43def4068f56b84027720f19970b3e073b1ee4 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 17:30:21 -0400 Subject: [PATCH 12/17] Used derivedState in ThreePaneAdaptiveTransform --- .../transforms/ThreePaneAdaptiveTransform.kt | 17 +++-- .../com/tunjid/demo/common/ui/DemoApp.kt | 76 +++++++++---------- 2 files changed, 48 insertions(+), 45 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt index 4694971..b9293ba 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt @@ -1,8 +1,10 @@ package com.tunjid.treenav.compose.threepane.transforms import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tunjid.treenav.Node @@ -23,19 +25,22 @@ fun tertiaryPaneBreakPoint: State = mutableStateOf(TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP), ): Transform = PaneTransform { destination, previousTransform -> - // Consider navigation state different if window size class changes - val windowWidthDp by windowWidthState + val showSecondary by remember { + derivedStateOf { windowWidthState.value >= secondaryPaneBreakPoint.value } + } + val showTertiary by remember { + derivedStateOf { windowWidthState.value >= tertiaryPaneBreakPoint.value } + } + val originalMapping = previousTransform(destination) val primaryNode = originalMapping[ThreePane.Primary] mapOf( ThreePane.Primary to primaryNode, ThreePane.Secondary to originalMapping[ThreePane.Secondary].takeIf { secondaryDestination -> - secondaryDestination?.id != primaryNode?.id - && windowWidthDp >= secondaryPaneBreakPoint.value + secondaryDestination?.id != primaryNode?.id && showSecondary }, ThreePane.Tertiary to originalMapping[ThreePane.Tertiary].takeIf { tertiaryDestination -> - tertiaryDestination?.id != primaryNode?.id - && windowWidthDp >= tertiaryPaneBreakPoint.value + tertiaryDestination?.id != primaryNode?.id && showTertiary }, ) } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt index 920b33e..e8962f4 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt @@ -133,50 +133,48 @@ fun App( modifier = Modifier .fillMaxSize(), state = appState.rememberMultiPaneDisplayState( - listOf( - threePanedAdaptiveTransform( - windowWidthState = remember { - derivedStateOf { + remember { + listOf( + threePanedAdaptiveTransform( + windowWidthState = derivedStateOf { appState.splitLayoutState.size } - } - ), - backPreviewTransform( - isPreviewingBack = remember { - derivedStateOf { + ), + backPreviewTransform( + isPreviewingBack = derivedStateOf { appState.isPreviewingBack - } - }, - navigationStateBackTransform = MultiStackNav::pop, - ), - threePanedMovableSharedElementTransform( - movableSharedElementHostState = movableSharedElementHostState - ), - paneModifierTransform { - val modifier = Modifier.animateBounds( - lookaheadScope = this@SharedTransitionScope, - boundsTransform = { _, _ -> - when (paneState.pane) { - ThreePane.Primary, - ThreePane.TransientPrimary, - ThreePane.Secondary, - ThreePane.Tertiary, - -> if (canAnimatePanes) spring() else snap() + }, + navigationStateBackTransform = MultiStackNav::pop, + ), + threePanedMovableSharedElementTransform( + movableSharedElementHostState = movableSharedElementHostState + ), + paneModifierTransform { + val modifier = Modifier.animateBounds( + lookaheadScope = this@SharedTransitionScope, + boundsTransform = { _, _ -> + when (paneState.pane) { + ThreePane.Primary, + ThreePane.TransientPrimary, + ThreePane.Secondary, + ThreePane.Tertiary, + -> if (canAnimatePanes) spring() else snap() - null, - ThreePane.Overlay, - -> snap() + null, + ThreePane.Overlay, + -> snap() + } } - } - ) - if (paneState.pane == ThreePane.TransientPrimary) modifier - .fillMaxSize() - .backPreview(appState.backPreviewState) - .background(backPreviewSurfaceColor, RoundedCornerShape(16.dp)) - else modifier - .fillMaxSize() - } - ) + ) + if (paneState.pane == ThreePane.TransientPrimary) modifier + .fillMaxSize() + .backPreview(appState.backPreviewState) + .background(backPreviewSurfaceColor, RoundedCornerShape(16.dp)) + else modifier + .fillMaxSize() + } + ) + } ), ) { appState.displayScope = this From a05bb6cbd927c7a45af5a280d4f32fe632e6a3ea Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 17:34:56 -0400 Subject: [PATCH 13/17] Update spelling of PaneStrategy to PaneEntry --- .../kotlin/com/tunjid/demo/common/ui/DemoApp.kt | 16 ++++++++-------- .../com/tunjid/demo/common/ui/chat/Strategy.kt | 2 +- .../tunjid/demo/common/ui/chatrooms/Strategy.kt | 2 +- .../com/tunjid/demo/common/ui/me/Strategy.kt | 2 +- .../tunjid/demo/common/ui/profile/Strategy.kt | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt index e8962f4..9bf3e53 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt @@ -70,12 +70,12 @@ import com.tunjid.composables.backpreview.backPreview import com.tunjid.composables.splitlayout.SplitLayout import com.tunjid.composables.splitlayout.SplitLayoutState import com.tunjid.demo.common.ui.AppState.Companion.rememberMultiPaneDisplayState -import com.tunjid.demo.common.ui.chat.chatPaneStrategy -import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneStrategy +import com.tunjid.demo.common.ui.chat.chatPaneEntry +import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneEntry import com.tunjid.demo.common.ui.data.NavigationRepository import com.tunjid.demo.common.ui.data.SampleDestination -import com.tunjid.demo.common.ui.me.mePaneStrategy -import com.tunjid.demo.common.ui.profile.profilePaneStrategy +import com.tunjid.demo.common.ui.me.mePaneEntry +import com.tunjid.demo.common.ui.profile.profilePaneEntry import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.backStack import com.tunjid.treenav.compose.MultiPaneDisplay @@ -350,13 +350,13 @@ class AppState( }, entryProvider = { destination -> when (destination) { - SampleDestination.NavTabs.ChatRooms -> chatRoomPaneStrategy() + SampleDestination.NavTabs.ChatRooms -> chatRoomPaneEntry() - SampleDestination.NavTabs.Me -> mePaneStrategy() + SampleDestination.NavTabs.Me -> mePaneEntry() - is SampleDestination.Chat -> chatPaneStrategy() + is SampleDestination.Chat -> chatPaneEntry() - is SampleDestination.Profile -> profilePaneStrategy() + is SampleDestination.Profile -> profilePaneEntry() } }, transforms = transforms, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Strategy.kt index a175546..2c31897 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Strategy.kt @@ -29,7 +29,7 @@ import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.threePaneEntry -fun chatPaneStrategy() = threePaneEntry( +fun chatPaneEntry() = threePaneEntry( paneMapping = { destination -> mapOf( ThreePane.Primary to destination, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt index d5541c3..7be32cd 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt @@ -27,7 +27,7 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.threePaneEntry -fun chatRoomPaneStrategy( +fun chatRoomPaneEntry( ) = threePaneEntry( render = { val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/Strategy.kt index 594fa6f..1434f13 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/Strategy.kt @@ -26,7 +26,7 @@ import com.tunjid.demo.common.ui.profile.ProfileViewModel import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.threePaneEntry -fun mePaneStrategy( +fun mePaneEntry( ) = threePaneEntry( render = { val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Strategy.kt index c229e19..e74b3f7 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Strategy.kt @@ -28,7 +28,7 @@ import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.threePaneEntry -fun profilePaneStrategy() = threePaneEntry( +fun profilePaneEntry() = threePaneEntry( paneMapping = { destination -> check(destination is SampleDestination.Profile) mapOf( From 24371ab459f5494cd74f010e69e1c5ea4675ee65 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 18:03:24 -0400 Subject: [PATCH 14/17] Make ViewModels mutators --- .../demo/common/ui/chat/ChatViewModel.kt | 19 ++++++++----------- .../common/ui/chatrooms/ChatRoomsViewModel.kt | 15 ++++++--------- .../common/ui/profile/ProfileViewModel.kt | 15 ++++++--------- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt index 50b5522..56c10d3 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt @@ -28,6 +28,7 @@ import com.tunjid.demo.common.ui.data.ProfileRepository import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.navigationAction import com.tunjid.demo.common.ui.data.navigationMutations +import com.tunjid.mutator.ActionStateMutator import com.tunjid.mutator.Mutation import com.tunjid.mutator.coroutines.actionStateFlowMutator import com.tunjid.mutator.coroutines.mapToMutation @@ -37,6 +38,7 @@ import com.tunjid.treenav.pop import com.tunjid.treenav.push import com.tunjid.treenav.swap import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -46,8 +48,8 @@ class ChatViewModel( profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, chat: SampleDestination.Chat, -) : ViewModel() { - private val mutator = coroutineScope.actionStateFlowMutator( +) : ViewModel(), + ActionStateMutator> by coroutineScope.actionStateFlowMutator( initialState = State(), inputs = listOf( profileRepository.meMutations(), @@ -72,16 +74,11 @@ class ChatViewModel( } ) - val state = mutator.state - - val accept = mutator.accept -} - private fun ProfileRepository.meMutations(): Flow> = me.mapToMutation { copy(me = it) } private fun ChatsRepository.chatRoomMutations( - chat: SampleDestination.Chat + chat: SampleDestination.Chat, ): Flow> = room(roomName = chat.roomName) .mapToMutation { copy(room = it) } @@ -115,7 +112,7 @@ data class State( val me: Profile? = null, val room: ChatRoom? = null, val isInPrimaryPane: Boolean = true, - val chats: List = emptyList() + val chats: List = emptyList(), ) data class MessageItem( @@ -124,11 +121,11 @@ data class MessageItem( ) sealed class Action( - val key: String + val key: String, ) { data class UpdateInPrimaryPane( - val isInPrimaryPane: Boolean + val isInPrimaryPane: Boolean, ) : Action("UpdateInPrimaryPane") sealed class Navigation : Action("Navigation"), NavigationAction { diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt index 2adbe9e..a798b20 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt @@ -25,19 +25,21 @@ import com.tunjid.demo.common.ui.data.NavigationRepository import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.navigationAction import com.tunjid.demo.common.ui.data.navigationMutations +import com.tunjid.mutator.ActionStateMutator import com.tunjid.mutator.Mutation import com.tunjid.mutator.coroutines.actionStateFlowMutator import com.tunjid.mutator.coroutines.mapToMutation import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.push import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow class ChatRoomsViewModel( coroutineScope: LifecycleCoroutineScope, chatsRepository: ChatsRepository = ChatsRepository, navigationRepository: NavigationRepository = NavigationRepository, -) : ViewModel() { - private val mutator = coroutineScope.actionStateFlowMutator( +) : ViewModel(), + ActionStateMutator> by coroutineScope.actionStateFlowMutator( initialState = State(), inputs = listOf( chatsRepository.loadMutations() @@ -55,22 +57,17 @@ class ChatRoomsViewModel( } ) - val state = mutator.state - - val accept = mutator.accept -} - private fun ChatsRepository.loadMutations(): Flow> = rooms.mapToMutation { copy(chatRooms = it) } data class State( - val chatRooms: List = emptyList() + val chatRooms: List = emptyList(), ) sealed class Action( - val key: String + val key: String, ) { sealed class Navigation : Action("Navigation"), NavigationAction { data class ToRoom( diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt index 2fdd5bc..bddc449 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt @@ -24,6 +24,7 @@ import com.tunjid.demo.common.ui.data.Profile import com.tunjid.demo.common.ui.data.ProfileRepository import com.tunjid.demo.common.ui.data.navigationAction import com.tunjid.demo.common.ui.data.navigationMutations +import com.tunjid.mutator.ActionStateMutator import com.tunjid.mutator.Mutation import com.tunjid.mutator.coroutines.actionStateFlowMutator import com.tunjid.mutator.coroutines.mapToMutation @@ -31,6 +32,7 @@ import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow class ProfileViewModel( coroutineScope: LifecycleCoroutineScope, @@ -38,8 +40,8 @@ class ProfileViewModel( navigationRepository: NavigationRepository = NavigationRepository, profileName: String?, roomName: String?, -) : ViewModel() { - private val mutator = coroutineScope.actionStateFlowMutator( +) : ViewModel(), + ActionStateMutator> by coroutineScope.actionStateFlowMutator( initialState = State( roomName = roomName, profileName = profileName, @@ -60,11 +62,6 @@ class ProfileViewModel( } ) - val state = mutator.state - - val accept = mutator.accept -} - private fun ProfileRepository.profileMutations( profileName: String?, ): Flow> = @@ -74,11 +71,11 @@ private fun ProfileRepository.profileMutations( data class State( val roomName: String? = null, val profileName: String? = null, - val profile: Profile? = null + val profile: Profile? = null, ) sealed class Action( - val key: String + val key: String, ) { sealed class Navigation : Action("Navigation"), NavigationAction { data object Pop : Navigation(), NavigationAction by navigationAction( From cdf0b1f8817b16cc1cd75737ac1383604d840d5c Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 18:36:33 -0400 Subject: [PATCH 15/17] Move DefaultViewModelStoreNavEntryDecorator to its own file --- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 192 +--------------- .../DefaultViewModelStoreNavEntryDecorator.kt | 217 ++++++++++++++++++ 2 files changed, 218 insertions(+), 191 deletions(-) create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/DefaultViewModelStoreNavEntryDecorator.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index e286dcd..6a278a3 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -34,31 +34,15 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf -import androidx.lifecycle.HasDefaultViewModelProviderFactory -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY -import androidx.lifecycle.SavedStateViewModelFactory -import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.enableSavedStateHandles -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.DecoratedNavEntryProvider import androidx.navigation3.NavEntry import androidx.navigation3.NavEntryDecorator import androidx.navigation3.SaveableStateNavEntryDecorator import androidx.navigation3.SavedStateNavEntryDecorator -import androidx.savedstate.SavedStateRegistryOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.decorators.DefaultViewModelStoreNavEntryDecorator @Composable internal fun DecoratedNavEntryMultiPaneDisplayScope( @@ -251,177 +235,3 @@ private val CastPlatformViewModelStoreNavEntryDecorator: NavEntryDecorator @Stable internal expect val PlatformViewModelStoreNavEntryDecorator: Any? -/** - * Provides the content of a [NavEntry] with a [ViewModelStoreOwner] and provides that - * [ViewModelStoreOwner] as a [LocalViewModelStoreOwner] so that it is available within the content. - * - * This requires that usage of the [SavedStateNavEntryDecorator] to ensure that the [NavEntry] - * scoped [ViewModel]s can properly provide access to [SavedStateHandle]s - */ -internal object DefaultViewModelStoreNavEntryDecorator : NavEntryDecorator { - - @Composable - override fun DecorateBackStack(backStack: List, content: @Composable () -> Unit) { - val entryViewModelStoreProvider = viewModel { EntryViewModel() } - entryViewModelStoreProvider.ownerInBackStack.clear() - entryViewModelStoreProvider.ownerInBackStack.addAll(backStack) - val localInfo = remember { ViewModelStoreNavLocalInfo() } - DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } - -// val activity = LocalActivity.current - backStack.forEachIndexed { index, key -> - // We update here as part of composition to ensure the value is available to - // DecorateEntry - localInfo.refCount.getOrPut(key) { LinkedHashSet() }.add(getIdForKey(key, index)) - DisposableEffect(key1 = key) { - localInfo.refCount - .getOrPut(key) { LinkedHashSet() } - .add(getIdForKey(key, index)) - onDispose { - // If the backStack count is less than the refCount for the key, remove the - // state since that means we removed a key from the backstack, and set the - // refCount to the backstack count. - val backstackCount = backStack.count { it == key } - val lastKeyCount = localInfo.refCount[key]?.size ?: 0 - if (backstackCount < lastKeyCount) { - // The set of the ids associated with this key - @Suppress("PrimitiveInCollection") // The order of the element matters - val idsSet = localInfo.refCount[key]!! - val id = idsSet.last() - idsSet.remove(id) - if (!localInfo.idsInComposition.contains(id)) { -// if (activity?.isChangingConfigurations != true) { - entryViewModelStoreProvider - .removeViewModelStoreOwnerForKey(id) - ?.clear() -// } - } - } - - // If the refCount is 0, remove the key from the refCount. - if (localInfo.refCount[key]?.isEmpty() == true) { - localInfo.refCount.remove(key) - } - } - } - } - - CompositionLocalProvider(LocalViewModelStoreNavLocalInfo provides localInfo) { - content.invoke() - } - } - - @Composable - override fun DecorateEntry(entry: NavEntry) { - val key = entry.key - val entryViewModelStoreProvider = viewModel { EntryViewModel() } - -// val activity = LocalActivity.current - val localInfo = LocalViewModelStoreNavLocalInfo.current - // Tracks whether the key is changed - var keyChanged = false - var id: Int = - rememberSaveable(key) { - keyChanged = true - localInfo.refCount[key]!!.last() - } - id = - rememberSaveable(localInfo.refCount[key]?.size) { - // if the key changed, use the current id - // If the key was not changed, and the current id is not in composition or on the - // back stack then update the id with the last item from the backstack with the - // associated key. This ensures that we can handle duplicates, both consecutive and - // non-consecutive - if ( - !keyChanged && - (!localInfo.idsInComposition.contains(id) || - localInfo.refCount[key]?.contains(id) == true) - ) { - localInfo.refCount[key]!!.last() - } else { - id - } - } - keyChanged = false - - val viewModelStore = entryViewModelStoreProvider.viewModelStoreForKey(id) - - DisposableEffect(key1 = key) { - localInfo.idsInComposition.add(id) - onDispose { - if (localInfo.idsInComposition.remove(id) && !localInfo.refCount.contains(key)) { -// if (activity?.isChangingConfigurations != true) { - entryViewModelStoreProvider.removeViewModelStoreOwnerForKey(id)?.clear() -// } - // If the refCount is 0, remove the key from the refCount. - if (localInfo.refCount[key]?.isEmpty() == true) { - localInfo.refCount.remove(key) - } - } - } - } - - val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current - val childViewModelOwner = remember { - object : - ViewModelStoreOwner, - SavedStateRegistryOwner by savedStateRegistryOwner, - HasDefaultViewModelProviderFactory { - override val viewModelStore: ViewModelStore - get() = viewModelStore - - override val defaultViewModelProviderFactory: ViewModelProvider.Factory - get() = SavedStateViewModelFactory() - - override val defaultViewModelCreationExtras: CreationExtras - get() = - MutableCreationExtras().also { - it[SAVED_STATE_REGISTRY_OWNER_KEY] = savedStateRegistryOwner - it[VIEW_MODEL_STORE_OWNER_KEY] = this - } - - init { - require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) { - "The Lifecycle state is already beyond INITIALIZED. The " + - "ViewModelStoreNavEntryDecorator requires adding the " + - "SavedStateNavEntryDecorator to ensure support for " + - "SavedStateHandles." - } - enableSavedStateHandles() - } - } - } - CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelOwner) { - entry.content.invoke(key) - } - } -} - -private class EntryViewModel : ViewModel() { - private val owners = mutableMapOf() - val ownerInBackStack = mutableListOf() - - fun viewModelStoreForKey(key: Any): ViewModelStore = owners.getOrPut(key) { ViewModelStore() } - - fun removeViewModelStoreOwnerForKey(key: Any): ViewModelStore? = owners.remove(key) - - override fun onCleared() { - owners.forEach { (_, store) -> store.clear() } - } -} - -internal val LocalViewModelStoreNavLocalInfo = - staticCompositionLocalOf { - error( - "CompositionLocal LocalViewModelStoreNavLocalInfo not present. You must call " + - "DecorateBackStack before calling DecorateEntry." - ) - } - -internal class ViewModelStoreNavLocalInfo { - internal val refCount: MutableMap> = mutableMapOf() - @Suppress("PrimitiveInCollection") // The order of the element matters - internal val idsInComposition: LinkedHashSet = LinkedHashSet() -} - -internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/DefaultViewModelStoreNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/DefaultViewModelStoreNavEntryDecorator.kt new file mode 100644 index 0000000..e24e6e2 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/DefaultViewModelStoreNavEntryDecorator.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2021 Google LLC + * + * 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.tunjid.treenav.compose.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.SavedStateViewModelFactory +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.NavEntry +import androidx.navigation3.NavEntryDecorator +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner + +/** + * Provides the content of a [NavEntry] with a [ViewModelStoreOwner] and provides that + * [ViewModelStoreOwner] as a [LocalViewModelStoreOwner] so that it is available within the content. + * + * This requires that usage of the [SavedStateNavEntryDecorator] to ensure that the [NavEntry] + * scoped [ViewModel]s can properly provide access to [SavedStateHandle]s + */ +internal object DefaultViewModelStoreNavEntryDecorator : NavEntryDecorator { + + @Composable + override fun DecorateBackStack(backStack: List, content: @Composable () -> Unit) { + val entryViewModelStoreProvider = viewModel { EntryViewModel() } + entryViewModelStoreProvider.ownerInBackStack.clear() + entryViewModelStoreProvider.ownerInBackStack.addAll(backStack) + val localInfo = remember { ViewModelStoreNavLocalInfo() } + DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } + +// val activity = LocalActivity.current + backStack.forEachIndexed { index, key -> + // We update here as part of composition to ensure the value is available to + // DecorateEntry + localInfo.refCount.getOrPut(key) { LinkedHashSet() }.add(getIdForKey(key, index)) + DisposableEffect(key1 = key) { + localInfo.refCount + .getOrPut(key) { LinkedHashSet() } + .add(getIdForKey(key, index)) + onDispose { + // If the backStack count is less than the refCount for the key, remove the + // state since that means we removed a key from the backstack, and set the + // refCount to the backstack count. + val backstackCount = backStack.count { it == key } + val lastKeyCount = localInfo.refCount[key]?.size ?: 0 + if (backstackCount < lastKeyCount) { + // The set of the ids associated with this key + @Suppress("PrimitiveInCollection") // The order of the element matters + val idsSet = localInfo.refCount[key]!! + val id = idsSet.last() + idsSet.remove(id) + if (!localInfo.idsInComposition.contains(id)) { +// if (activity?.isChangingConfigurations != true) { + entryViewModelStoreProvider + .removeViewModelStoreOwnerForKey(id) + ?.clear() +// } + } + } + + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + CompositionLocalProvider(LocalViewModelStoreNavLocalInfo provides localInfo) { + content.invoke() + } + } + + @Composable + override fun DecorateEntry(entry: NavEntry) { + val key = entry.key + val entryViewModelStoreProvider = viewModel { EntryViewModel() } + +// val activity = LocalActivity.current + val localInfo = LocalViewModelStoreNavLocalInfo.current + // Tracks whether the key is changed + var keyChanged = false + var id: Int = + rememberSaveable(key) { + keyChanged = true + localInfo.refCount[key]!!.last() + } + id = + rememberSaveable(localInfo.refCount[key]?.size) { + // if the key changed, use the current id + // If the key was not changed, and the current id is not in composition or on the + // back stack then update the id with the last item from the backstack with the + // associated key. This ensures that we can handle duplicates, both consecutive and + // non-consecutive + if ( + !keyChanged && + (!localInfo.idsInComposition.contains(id) || + localInfo.refCount[key]?.contains(id) == true) + ) { + localInfo.refCount[key]!!.last() + } else { + id + } + } + keyChanged = false + + val viewModelStore = entryViewModelStoreProvider.viewModelStoreForKey(id) + + DisposableEffect(key1 = key) { + localInfo.idsInComposition.add(id) + onDispose { + if (localInfo.idsInComposition.remove(id) && !localInfo.refCount.contains(key)) { +// if (activity?.isChangingConfigurations != true) { + entryViewModelStoreProvider.removeViewModelStoreOwnerForKey(id)?.clear() +// } + // If the refCount is 0, remove the key from the refCount. + if (localInfo.refCount[key]?.isEmpty() == true) { + localInfo.refCount.remove(key) + } + } + } + } + + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + val childViewModelOwner = remember { + object : + ViewModelStoreOwner, + SavedStateRegistryOwner by savedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + override val viewModelStore: ViewModelStore + get() = viewModelStore + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = SavedStateViewModelFactory() + + override val defaultViewModelCreationExtras: CreationExtras + get() = + MutableCreationExtras().also { + it[SAVED_STATE_REGISTRY_OWNER_KEY] = savedStateRegistryOwner + it[VIEW_MODEL_STORE_OWNER_KEY] = this + } + + init { + require(this.lifecycle.currentState == Lifecycle.State.INITIALIZED) { + "The Lifecycle state is already beyond INITIALIZED. The " + + "ViewModelStoreNavEntryDecorator requires adding the " + + "SavedStateNavEntryDecorator to ensure support for " + + "SavedStateHandles." + } + enableSavedStateHandles() + } + } + } + CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelOwner) { + entry.content.invoke(key) + } + } +} + +private class EntryViewModel : ViewModel() { + private val owners = mutableMapOf() + val ownerInBackStack = mutableListOf() + + fun viewModelStoreForKey(key: Any): ViewModelStore = owners.getOrPut(key) { ViewModelStore() } + + fun removeViewModelStoreOwnerForKey(key: Any): ViewModelStore? = owners.remove(key) + + override fun onCleared() { + owners.forEach { (_, store) -> store.clear() } + } +} + +internal val LocalViewModelStoreNavLocalInfo = + staticCompositionLocalOf { + error( + "CompositionLocal LocalViewModelStoreNavLocalInfo not present. You must call " + + "DecorateBackStack before calling DecorateEntry." + ) + } + +internal class ViewModelStoreNavLocalInfo { + internal val refCount: MutableMap> = mutableMapOf() + @Suppress("PrimitiveInCollection") // The order of the element matters + internal val idsInComposition: LinkedHashSet = LinkedHashSet() +} + +internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count \ No newline at end of file From b753729db505527864e875a071afbcf46b1de86f Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 18:46:58 -0400 Subject: [PATCH 16/17] Copy TransitionAwareLifecycleNavEntryDecorator, but don't use 'isSettled' yet --- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 6 + ...ansitionAwareLifecycleNavEntryDecorator.kt | 124 ++++++++++++++++++ .../demo/common/ui/chat/ChatViewModel.kt | 2 +- .../common/ui/chatrooms/ChatRoomsViewModel.kt | 2 +- .../common/ui/profile/ProfileViewModel.kt | 2 +- 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/TransitionAwareLifecycleNavEntryDecorator.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index 6a278a3..ece7241 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -43,6 +43,7 @@ import androidx.navigation3.SaveableStateNavEntryDecorator import androidx.navigation3.SavedStateNavEntryDecorator import com.tunjid.treenav.Node import com.tunjid.treenav.compose.decorators.DefaultViewModelStoreNavEntryDecorator +import com.tunjid.treenav.compose.decorators.TransitionAwareLifecycleNavEntryDecorator @Composable internal fun DecoratedNavEntryMultiPaneDisplayScope( @@ -60,6 +61,10 @@ internal fun DecoratedNavEntr state.destinationTransform(navigationState) ) + val transitionAwareLifecycleNavEntryDecorator = remember { + TransitionAwareLifecycleNavEntryDecorator() + } + DecoratedNavEntryProvider( backStack = backStack, entryProvider = { node -> @@ -75,6 +80,7 @@ internal fun DecoratedNavEntr entryDecorators = listOf( SaveableStateNavEntryDecorator, SavedStateNavEntryDecorator, + transitionAwareLifecycleNavEntryDecorator, CastPlatformViewModelStoreNavEntryDecorator, ), content = { entries -> diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/TransitionAwareLifecycleNavEntryDecorator.kt new file mode 100644 index 0000000..1e1a849 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/decorators/TransitionAwareLifecycleNavEntryDecorator.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2021 Google LLC + * + * 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.tunjid.treenav.compose.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation3.NavEntry +import androidx.navigation3.NavEntryDecorator + +internal class TransitionAwareLifecycleNavEntryDecorator : NavEntryDecorator { + + var isSettled by mutableStateOf(true) + + @Composable + override fun DecorateBackStack(backStack: List, content: @Composable (() -> Unit)) { + val localInfo = remember(backStack) { TransitionAwareLifecycleNavLocalInfo(backStack) } + CompositionLocalProvider(LocalTransitionAwareLifecycleNavLocalInfo provides localInfo) { + content.invoke() + } + } + + @Composable + override fun DecorateEntry(entry: NavEntry) { + val backStack = LocalTransitionAwareLifecycleNavLocalInfo.current.backStack + // TODO: Handle duplicate keys + val isInBackStack = entry.key in backStack + val maxLifecycle = + when { + isInBackStack && isSettled -> Lifecycle.State.RESUMED + isInBackStack && !isSettled -> Lifecycle.State.STARTED + else /* !isInBackStack */ -> Lifecycle.State.CREATED + } + LifecycleOwner(maxLifecycle = maxLifecycle) { entry.content.invoke(entry.key) } + } +} + +private val LocalTransitionAwareLifecycleNavLocalInfo = + compositionLocalOf { + error( + "CompositionLocal LocalTransitionAwareLifecycleNavLocalInfo not present. You must " + + "call DecorateBackStack before calling DecorateEntry." + ) + } + +private class TransitionAwareLifecycleNavLocalInfo(val backStack: List) + +@Composable +private fun LifecycleOwner( + maxLifecycle: Lifecycle.State = Lifecycle.State.RESUMED, + parentLifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + content: @Composable () -> Unit, +) { + val childLifecycleOwner = remember(parentLifecycleOwner) { ChildLifecycleOwner() } + // Pass LifecycleEvents from the parent down to the child + DisposableEffect(childLifecycleOwner, parentLifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + childLifecycleOwner.handleLifecycleEvent(event) + } + + parentLifecycleOwner.lifecycle.addObserver(observer) + + onDispose { parentLifecycleOwner.lifecycle.removeObserver(observer) } + } + // Ensure that the child lifecycle is capped at the maxLifecycle + LaunchedEffect(childLifecycleOwner, maxLifecycle) { + childLifecycleOwner.maxLifecycle = maxLifecycle + } + // Now install the LifecycleOwner as a composition local + CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) { content.invoke() } +} + +private class ChildLifecycleOwner : LifecycleOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + var maxLifecycle: Lifecycle.State = Lifecycle.State.INITIALIZED + set(maxState) { + field = maxState + updateState() + } + + private var parentLifecycleState: Lifecycle.State = Lifecycle.State.CREATED + + fun handleLifecycleEvent(event: Lifecycle.Event) { + parentLifecycleState = event.targetState + updateState() + } + + fun updateState() { + if (parentLifecycleState.ordinal < maxLifecycle.ordinal) { + lifecycleRegistry.currentState = parentLifecycleState + } else { + lifecycleRegistry.currentState = maxLifecycle + } + } +} \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt index 56c10d3..384b5a3 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt @@ -48,7 +48,7 @@ class ChatViewModel( profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, chat: SampleDestination.Chat, -) : ViewModel(), +) : ViewModel(coroutineScope), ActionStateMutator> by coroutineScope.actionStateFlowMutator( initialState = State(), inputs = listOf( diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt index a798b20..64301fb 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt @@ -38,7 +38,7 @@ class ChatRoomsViewModel( coroutineScope: LifecycleCoroutineScope, chatsRepository: ChatsRepository = ChatsRepository, navigationRepository: NavigationRepository = NavigationRepository, -) : ViewModel(), +) : ViewModel(coroutineScope), ActionStateMutator> by coroutineScope.actionStateFlowMutator( initialState = State(), inputs = listOf( diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt index bddc449..33f1067 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt @@ -40,7 +40,7 @@ class ProfileViewModel( navigationRepository: NavigationRepository = NavigationRepository, profileName: String?, roomName: String?, -) : ViewModel(), +) : ViewModel(coroutineScope), ActionStateMutator> by coroutineScope.actionStateFlowMutator( initialState = State( roomName = roomName, From fc28ecbd978ba14b741b34c5b63245ff3824570c Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 27 Apr 2025 18:52:46 -0400 Subject: [PATCH 17/17] Update docs for Pop adaptation --- .../kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt index cd11573..01e02e2 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavigationState.kt @@ -17,8 +17,7 @@ sealed class Adaptation { data object Change : Adaptation() /** - * The current navigation state is considered identical - * to that preceding the penultimate navigation state. + * The current back stack is a sublist of a previously displayed back stack. */ data object Pop : Adaptation()