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..5b7ca99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,36 @@ [versions] -androidGradlePlugin = "8.5.2" +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" 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" -kotlinxCoroutines = "1.9.0" -kotlinxDatetime = "0.6.1" -lifecycle-runtime = "2.8.6" +kotlin = "2.1.20" +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" 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 +47,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 +57,16 @@ 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-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" } #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..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() } @@ -43,8 +44,19 @@ kotlin { implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.jetbrains.lifecycle.viewmodel.compose) + + + implementation(libs.androidx.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/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt new file mode 100644 index 0000000..ece7241 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -0,0 +1,243 @@ +/* + * 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.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.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 +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.navigation3.DecoratedNavEntryProvider +import androidx.navigation3.NavEntry +import androidx.navigation3.NavEntryDecorator +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( + state: MultiPaneDisplayState, + content: @Composable (MultiPaneDisplayScope.() -> Unit), +) { + val navigationState by state.navigationState + val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> + state.backStackTransform(navigationState).let { currentBackStack -> + mutableBackStack.clear() + mutableBackStack.addAll(currentBackStack) + } + } + val panesToDestinations = state.panesToDestinationsTransform( + state.destinationTransform(navigationState) + ) + + val transitionAwareLifecycleNavEntryDecorator = remember { + TransitionAwareLifecycleNavEntryDecorator() + } + + 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, + transitionAwareLifecycleNavEntryDecorator, + CastPlatformViewModelStoreNavEntryDecorator, + ), + content = { entries -> + val updatedEntries by rememberUpdatedState(entries) + val displayScope = remember { + DecoratedNavEntryMultiPaneDisplayScope( + panes = state.panes, + initialBackStack = backStack, + initialPanesToDestinations = panesToDestinations, + paneRenderer = { + val currentEntry = remember(paneState.currentDestination?.id) { + updatedEntries.findLast { + it.key.id == paneState.currentDestination?.id + } + } + checkNotNull(currentEntry) { + "There is no entry for the current navigation destination with id ${paneState.currentDestination?.id}" + }.content(currentEntry.key) + }, + ) + } + DisposableEffect(navigationState, panesToDestinations) { + displayScope.onBackStackChanged( + backStackIds = backStack.map { it.id }, + panesToDestinations = panesToDestinations + ) + onDispose { } + } + + displayScope.content() + }, + ) +} + +@Stable +private class DecoratedNavEntryMultiPaneDisplayScope( + panes: List, + initialBackStack: List, + initialPanesToDestinations: 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, + panesToDestinations = initialPanesToDestinations, + backStackIds = initialBackStack.map { it.id }, + ) + ) + + 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( + backStackIds: List, + panesToDestinations: Map, + ) { + updateAdaptiveNavigationState { + adaptTo( + slots = slots.toSet(), + panesToDestinations = panesToDestinations, + backStackIds = backStackIds, + ) + } + } + + /** + * 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() + } + } + } + } + + private inline fun updateAdaptiveNavigationState( + block: SlotBasedPanedNavigationState.() -> SlotBasedPanedNavigationState, + ) { + panedNavigationState = panedNavigationState.block() + } +} + +private val LocalPaneScope = staticCompositionLocalOf> { + throw IllegalArgumentException( + "PaneScope should not be read until provided in the composition" + ) +} + +@Stable +private val CastPlatformViewModelStoreNavEntryDecorator: NavEntryDecorator + get() = PlatformViewModelStoreNavEntryDecorator as? NavEntryDecorator + ?: DefaultViewModelStoreNavEntryDecorator + +@Stable +internal expect val PlatformViewModelStoreNavEntryDecorator: Any? + 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..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,7 +79,7 @@ fun MultiPaneDisplay( Box( modifier = modifier ) { - SlottedMultiPaneDisplayScope( + DecoratedNavEntryMultiPaneDisplayScope( state = state, content = content ) 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 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..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 @@ -16,6 +16,11 @@ sealed class Adaptation { */ data object Change : Adaptation() + /** + * The current back stack is a sublist of a previously displayed back stack. + */ + data object Pop : Adaptation() + /** * Destinations were swapped in between panes */ 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/SlotBasedPanedNavigationState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt index 471ed00..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 } } @@ -123,8 +129,8 @@ internal data class SlotBasedPanedNavigationState( */ internal fun SlotBasedPanedNavigationState.adaptTo( slots: Set, - panesToNodes: Map, - backStackIds: Set, + panesToDestinations: Map, + backStackIds: List, ): SlotBasedPanedNavigationState { val previous = this @@ -139,13 +145,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 @@ -179,9 +185,18 @@ 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 }) { - panesToNodes.mapValues { it.value?.id } -> previous.swapAdaptations + panesToDestinations.mapValues { it.value?.id } -> previous.swapAdaptations else -> swapAdaptations }, previousPanesToDestinations = previous.panesToDestinations.keys.associateWith( @@ -189,7 +204,7 @@ internal fun SlotBasedPanedNavigationState.adaptTo( ), destinationIdsToAdaptiveSlots = nodeIdsToAdaptiveSlots, backStackIds = backStackIds, - panesToDestinations = panesToNodes, + panesToDestinations = panesToDestinations, destinationIdsAnimatingOut = previous.destinationIdsAnimatingOut, ) 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 -//} 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 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/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/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/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 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/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 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/build.gradle.kts b/sample/common/build.gradle.kts index 09d7a3c..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) @@ -55,6 +56,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/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..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 @@ -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 @@ -344,7 +342,6 @@ class AppState( placeChildrenBeforeParent = true, ) .filterIsInstance() - .toList() }, destinationTransform = { it.current as? SampleDestination ?: throw IllegalArgumentException( @@ -353,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/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt index 50b5522..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 @@ -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(coroutineScope), + 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/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/ChatRoomsViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt index 2adbe9e..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 @@ -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(coroutineScope), + 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/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/ProfileViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt index 2fdd5bc..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 @@ -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(coroutineScope), + 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( 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( 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") + } } }