From 7421c3d7b8eaa18a6da26b9b669e7aa683117276 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 5 May 2025 10:21:59 -0400 Subject: [PATCH 1/6] Version bump --- libraryVersion.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraryVersion.properties b/libraryVersion.properties index a43a917..225ae0f 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,7 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.22 -strings_version=0.0.22 -compose_version=0.0.22 -compose-threepane_version=0.0.22 \ No newline at end of file +treenav_version=0.0.23 +strings_version=0.0.23 +compose_version=0.0.23 +compose-threepane_version=0.0.23 \ No newline at end of file From 3f32acad8158e6bf56ae8016e46a41782a0b2a16 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 10 May 2025 08:53:52 -0400 Subject: [PATCH 2/6] Add MovableContentNavEntryDecorator for saved state movable content semantics --- .../MovableSharedElementTransform.kt | 18 ++- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 4 +- .../com/tunjid/treenav/compose/StackNavExt.kt | 46 +++++++ .../MovableContentNavEntryDecorator.kt | 128 ++++++++++++++++++ .../SaveableStateNavEntryDecorator.kt | 92 +------------ .../SlotBasedAdaptiveNavigationStateTest.kt | 16 --- .../com/tunjid/treenav/MultiStackNav.kt | 9 +- .../kotlin/com/tunjid/treenav/StackNav.kt | 9 +- .../com/tunjid/demo/common/ui/DemoApp.kt | 8 +- 9 files changed, 211 insertions(+), 119 deletions(-) create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 906ab36..71ba894 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -17,10 +17,12 @@ package com.tunjid.treenav.compose.threepane.transforms import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope.OverlayClip import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize +import androidx.compose.animation.core.Transition import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -29,12 +31,13 @@ import androidx.compose.ui.Modifier import com.tunjid.treenav.Node import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.transforms.RenderTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.PaneState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.PaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.transforms.RenderTransform +import com.tunjid.treenav.compose.transforms.Transform /** * A [Transform] that applies semantics of movable shared elements to @@ -91,11 +94,20 @@ private class ThreePaneMovableSharedElementScope( private val hostState: MovableSharedElementHostState, private val delegate: PaneMovableSharedElementScope, ) : MovableSharedElementScope, - PaneScope by delegate.paneScope { + PaneScope { override val sharedTransitionScope: SharedTransitionScope get() = delegate.sharedTransitionScope + override val transition: Transition + get() = delegate.paneScope.transition + + override val paneState: PaneState + get() = delegate.paneScope.paneState + + override val isActive: Boolean + get() = delegate.paneScope.isActive + @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( key: Any, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index 1cf35b5..6809e24 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -39,8 +39,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import com.tunjid.treenav.Node import com.tunjid.treenav.compose.navigation3.DecoratedNavEntryProvider import com.tunjid.treenav.compose.navigation3.NavEntry -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.DefaultViewModelStoreNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.decorators.MovableContentNavEntryDecorator import com.tunjid.treenav.compose.navigation3.decorators.SaveableStateNavEntryDecorator import com.tunjid.treenav.compose.navigation3.decorators.SavedStateNavEntryDecorator import com.tunjid.treenav.compose.navigation3.decorators.TransitionAwareLifecycleNavEntryDecorator @@ -79,6 +78,7 @@ internal fun DecoratedNavEntr ) }, entryDecorators = listOf( + MovableContentNavEntryDecorator, SaveableStateNavEntryDecorator, SavedStateNavEntryDecorator, transitionAwareLifecycleNavEntryDecorator, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt new file mode 100644 index 0000000..abe26c5 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt @@ -0,0 +1,46 @@ +/* + * 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 com.tunjid.treenav.MultiStackNav +import com.tunjid.treenav.Node +import com.tunjid.treenav.StackNav +import com.tunjid.treenav.backStack + +/** + * A convenience method for reading the back stack for this [MultiStackNav] + * optimized for consumption for a [MultiPaneDisplay]. + */ +inline fun MultiStackNav.multiPaneDisplayBackstack() = + backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + distinctDestinations = true, + ) + .filterIsInstance() + +/** + * A convenience method for reading the back stack for this [MultiStackNav] + * optimized for consumption for a [MultiPaneDisplay]. + */ +inline fun StackNav.multiPaneDisplayBackstack() = + backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + distinctDestinations = true, + ) + .filterIsInstance() \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt new file mode 100644 index 0000000..5be9c83 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt @@ -0,0 +1,128 @@ +/* + * 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.navigation3.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.key +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.NavEntryDecorator + + +/** + * A [NavEntryDecorator] that wraps each entry in a [movableContentOf] to allow nav displays to + * arbitrarily place entries in different places in the composable call hierarchy. + * + * This should likely be the first [NavEntryDecorator] to ensure that other + * [NavEntryDecorator.DecorateEntry] calls that are stateful are moved properly inside the + * [movableContentOf]. + */ +internal object MovableContentNavEntryDecorator : NavEntryDecorator { + + @Composable + override fun DecorateBackStack(backStack: List, content: @Composable (() -> Unit)) { + val backStackKeys = backStack.toSet() + + // This is an intricate dance to create a movableContentOf for each entry that is scoped + // to the backstack, that calls the correct updated content. + // First we associate each key in the backstack with a MutableState that will contain + // the actual content of the entry, as updated in DecorateEntry. + // The MutableState's remembered lifecycle precisely matches when its key is in the + // backstack. + val movableContentContentHolderMap: Map Unit>> = + backStackKeys.associateWith { key -> + key(key) { + remember { + mutableStateOf( + @Composable { + error( + "Should not be called, this should always be updated in" + + "DecorateEntry with the real content" + ) + } + ) + } + } + } + + // Second we create another map containing the movable contents themselves, again + // by associating the backstack key with a remembered movableContentOf + // The critical thing here is that the movableContentOf's remembered lifecycle precisely + // matches when its key is in the backstack. + val movableContentHolderMap: Map Unit> = + backStackKeys.associateWith { key -> + key(key) { + remember { + movableContentOf { + // In case the key is removed from the backstack while this is still + // being rendered, we remember the MutableState directly to allow + // rendering it while we are animating out. + remember { movableContentContentHolderMap.getValue(key) }.value() + } + } + } + } + CompositionLocalProvider( + LocalMovableContentNavLocalInfo provides + MovableContentNavLocalInfo(movableContentHolderMap, movableContentContentHolderMap), + content = content, + ) + } + + @Composable + override fun DecorateEntry(entry: NavEntry) { + val movableContentNavLocalInfo = LocalMovableContentNavLocalInfo.current + key(entry.key) { + // In case the key is removed from the backstack while this is still + // being rendered, we remember the MutableState directly to allow + // updating it while we are animating out. + val movableContentContentHolder = remember { + movableContentNavLocalInfo.movableContentContentHolderMap.getValue(entry.key) + } + // Update the state holder with the actual entry content + movableContentContentHolder.value = { entry.content(entry.key) } + // In case the key is removed from the backstack while this is still + // being rendered, we remember the movableContent directly to allow + // rendering it while we are animating out. + val movableContentHolder = remember { + movableContentNavLocalInfo.movableContentHolderMap.getValue(entry.key) + } + // Finally, render the entry content via the movableContentOf + movableContentHolder() + } + } +} + +internal val LocalMovableContentNavLocalInfo = + staticCompositionLocalOf { + error( + "CompositionLocal LocalMovableContentNavLocalInfo not present. You must call " + + "DecorateBackStack before calling DecorateEntry." + ) + } + +@Immutable +internal class MovableContentNavLocalInfo( + val movableContentHolderMap: Map Unit>, + val movableContentContentHolderMap: Map Unit>> +) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt index 5e92cea..59fd04b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt @@ -16,17 +16,13 @@ package com.tunjid.treenav.compose.navigation3.decorators -import androidx.collection.mutableScatterMapOf import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.ReusableContent 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 +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.staticCompositionLocalOf import com.tunjid.treenav.compose.navigation3.NavEntry import com.tunjid.treenav.compose.navigation3.NavEntryDecorator @@ -45,7 +41,7 @@ internal object SaveableStateNavEntryDecorator : NavEntryDecorator { val localInfo = remember { SaveableStateNavLocalInfo() } DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } } - localInfo.savedStateHolder = rememberPanedSaveableStateHolder() + localInfo.savedStateHolder = rememberSaveableStateHolder() backStack.forEachIndexed { index, key -> // We update here as part of composition to ensure the value is available to // DecorateEntry @@ -147,87 +143,3 @@ internal class SaveableStateNavLocalInfo { @Suppress("PrimitiveInCollection") // The order of the element matters internal val idsInComposition: LinkedHashSet = LinkedHashSet() } - -@Composable -internal fun rememberPanedSaveableStateHolder(): SaveableStateHolder = - rememberSaveable( - saver = PanedSavableStateHolder.Saver - ) { - PanedSavableStateHolder() - }.apply { - parentSaveableStateRegistry = LocalSaveableStateRegistry.current - } - -private class PanedSavableStateHolder( - private val savedStates: MutableMap>> = mutableMapOf() -) : SaveableStateHolder { - private val registries = mutableScatterMapOf() - var parentSaveableStateRegistry: SaveableStateRegistry? = null - private val canBeSaved: (Any) -> Boolean = { - parentSaveableStateRegistry?.canBeSaved(it) ?: true - } - - @Composable - override fun SaveableStateProvider(key: Any, content: @Composable () -> Unit) { - ReusableContent(key) { - val registry = remember { - require(canBeSaved(key)) { - "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 - registries[key]?.saveTo(savedStates, key) - SaveableStateRegistry(savedStates[key], canBeSaved) - } - CompositionLocalProvider( - LocalSaveableStateRegistry provides registry, - content = content - ) - DisposableEffect(Unit) { - require(key !in registries) { "Key $key was used multiple times " } - savedStates -= key - registries[key] = registry - onDispose { - if (registries.remove(key) === registry) { - registry.saveTo(savedStates, key) - } - } - } - } - } - - private fun saveAll(): MutableMap>>? { - val map = savedStates - registries.forEach { key, registry -> registry.saveTo(map, key) } - return map.ifEmpty { null } - } - - override fun removeState(key: Any) { - if (registries.remove(key) == null) { - savedStates -= key - } - } - - private fun SaveableStateRegistry.saveTo( - map: MutableMap>>, - key: Any - ) { - val savedData = 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/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt index 2589d39..8ab13b7 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 @@ -28,22 +28,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -/* - * 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. - */ - data class TestNode( val name: String, override val children: List = emptyList(), 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 8eca493..d8deac5 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt @@ -121,16 +121,23 @@ fun MultiStackNav.reversedBackStackSequence( * [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. + * @param distinctDestinations when true, only the first instance of a destination will be present + * in the backstack. This is the default, otherwise, all destinations will be returned. */ fun MultiStackNav.backStack( includeCurrentDestinationChildren: Boolean, placeChildrenBeforeParent: Boolean = false, + distinctDestinations: Boolean = false, ): List = reversedBackStackSequence( includeCurrentDestinationChildren = includeCurrentDestinationChildren, placeChildrenBeforeParent = !placeChildrenBeforeParent ) .toList() .asReversed() + .let { + if (distinctDestinations) it.distinct() + else it + } /** * Performs the given [operation] with the [StackNav] at [MultiStackNav.currentIndex] @@ -144,7 +151,7 @@ private inline fun MultiStackNav.atCurrentIndex(operation: StackNav.() -> StackN val MultiStackNav.current: Node? get() = stacks.getOrNull(currentIndex)?.children?.lastOrNull() -inline fun MultiStackNav.current(): T? { +inline fun MultiStackNav.current(): T? { val node = current ?: return null check(node is T) { "Expected the current node to be of type ${T::class} but was ${node::class}." 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 fd1299e..cbed1ec 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt @@ -97,16 +97,23 @@ fun StackNav.reversedBackStackSequence( * [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. + * @param distinctDestinations when true, only the first instance of a destination will be present + * in the backstack. This is the default, otherwise, all destinations will be returned. */ fun StackNav.backStack( includeCurrentDestinationChildren: Boolean, placeChildrenBeforeParent: Boolean = false, + distinctDestinations: Boolean = false, ): List = reversedBackStackSequence( includeCurrentDestinationChildren = includeCurrentDestinationChildren, placeChildrenBeforeParent = !placeChildrenBeforeParent ) .toList() .asReversed() + .let { + if (distinctDestinations) it.distinct() + else it + } /** * Indicates if there's a [Node] available to pop up to @@ -115,7 +122,7 @@ val StackNav.canPop: Boolean get() = children.size > 1 val StackNav.current: Node? get() = children.lastOrNull() -inline fun StackNav.current(): T? { +inline fun StackNav.current(): T? { val node = current ?: return null check(node is T) { "Expected the current node to be of type ${T::class.simpleName} but was ${node::class.simpleName}." 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 8500e39..5f4c9d2 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 @@ -75,12 +75,12 @@ import com.tunjid.demo.common.ui.me.mePaneEntry import com.tunjid.demo.common.ui.avatar.avatarPaneEntry import com.tunjid.demo.common.ui.profile.profilePaneEntry import com.tunjid.treenav.MultiStackNav -import com.tunjid.treenav.backStack import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.MultiPaneDisplayScope import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.multiPaneDisplayBackstack import com.tunjid.treenav.compose.threepane.transforms.backPreviewTransform import com.tunjid.treenav.compose.threepane.transforms.threePanedAdaptiveTransform import com.tunjid.treenav.compose.threepane.transforms.threePanedMovableSharedElementTransform @@ -312,11 +312,7 @@ class AppState( panes = ThreePane.entries.toList(), navigationState = navigationState, backStackTransform = { multiStackNav -> - multiStackNav.backStack( - includeCurrentDestinationChildren = true, - placeChildrenBeforeParent = true, - ) - .filterIsInstance() + multiStackNav.multiPaneDisplayBackstack() }, destinationTransform = { it.current as? SampleDestination ?: throw IllegalArgumentException( From 3b88ee4247c98254492541df20670a65244fa06e Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 10 May 2025 11:51:30 -0400 Subject: [PATCH 3/6] rename current to requireCurrent --- .../kotlin/com/tunjid/treenav/compose/StackNavExt.kt | 4 ++-- .../compose/SlotBasedAdaptiveNavigationStateTest.kt | 6 +++--- .../kotlin/com/tunjid/treenav/MultiStackNav.kt | 6 +++--- .../commonMain/kotlin/com/tunjid/treenav/StackNav.kt | 6 +++--- libraryVersion.properties | 8 ++++---- .../kotlin/com/tunjid/demo/common/ui/DemoApp.kt | 12 +++--------- 6 files changed, 18 insertions(+), 24 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt index abe26c5..87a7f56 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt @@ -25,7 +25,7 @@ import com.tunjid.treenav.backStack * A convenience method for reading the back stack for this [MultiStackNav] * optimized for consumption for a [MultiPaneDisplay]. */ -inline fun MultiStackNav.multiPaneDisplayBackstack() = +inline fun MultiStackNav.multiPaneDisplayBackstack(): List = backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, @@ -37,7 +37,7 @@ inline fun MultiStackNav.multiPaneDisplayBackstack() * A convenience method for reading the back stack for this [MultiStackNav] * optimized for consumption for a [MultiPaneDisplay]. */ -inline fun StackNav.multiPaneDisplayBackstack() = +inline fun StackNav.multiPaneDisplayBackstack(): List = backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, 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 8ab13b7..3b5594c 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 @@ -19,7 +19,7 @@ package com.tunjid.treenav.compose import com.tunjid.treenav.Node import com.tunjid.treenav.StackNav import com.tunjid.treenav.backStack -import com.tunjid.treenav.current +import com.tunjid.treenav.requireCurrent import com.tunjid.treenav.pop import com.tunjid.treenav.push import kotlin.test.BeforeTest @@ -559,7 +559,7 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = navStates[index], panesToDestinations = mapOf( - TestPane.One to navStates[index].current(), + TestPane.One to navStates[index].requireCurrent(), ) ) .apply { @@ -576,7 +576,7 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = poppedNavStates[index], panesToDestinations = mapOf( - TestPane.One to navStates[index].current(), + TestPane.One to navStates[index].requireCurrent(), ) ) .apply { 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 d8deac5..a6b105d 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt @@ -31,7 +31,7 @@ data class MultiStackNav( } /** - * Switches out the [current] for [Node] + * Switches out the [requireCurrent] for [Node] */ fun MultiStackNav.swap(node: Node): MultiStackNav = atCurrentIndex { swap(node) } @@ -151,8 +151,8 @@ 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 +inline fun MultiStackNav.requireCurrent(): T { + val node = current ?: throw IllegalArgumentException("The current node is null") check(node is T) { "Expected the current node to be of type ${T::class} but was ${node::class}." } 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 cbed1ec..2434ab9 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt @@ -34,7 +34,7 @@ fun StackNav.swap(node: Node): StackNav = else copy(children = children.dropLast(1) + node) /** - * Pushes the [node] unto the top of the navigation stack if [current] is not equal to [node] + * Pushes the [node] unto the top of the navigation stack if [requireCurrent] is not equal to [node] */ fun StackNav.push(node: Node): StackNav = if (children.lastOrNull() == node) this @@ -122,8 +122,8 @@ val StackNav.canPop: Boolean get() = children.size > 1 val StackNav.current: Node? get() = children.lastOrNull() -inline fun StackNav.current(): T? { - val node = current ?: return null +inline fun StackNav.requireCurrent(): T { + val node = current ?: throw IllegalArgumentException("The current node is null") check(node is T) { "Expected the current node to be of type ${T::class.simpleName} but was ${node::class.simpleName}." } diff --git a/libraryVersion.properties b/libraryVersion.properties index 225ae0f..621d8e5 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,7 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.23 -strings_version=0.0.23 -compose_version=0.0.23 -compose-threepane_version=0.0.23 \ No newline at end of file +treenav_version=0.0.24 +strings_version=0.0.24 +compose_version=0.0.24 +compose-threepane_version=0.0.24 \ No newline at end of file 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 5f4c9d2..4d3ab68 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 @@ -86,7 +86,7 @@ import com.tunjid.treenav.compose.threepane.transforms.threePanedAdaptiveTransfo import com.tunjid.treenav.compose.threepane.transforms.threePanedMovableSharedElementTransform import com.tunjid.treenav.compose.transforms.Transform import com.tunjid.treenav.compose.transforms.paneModifierTransform -import com.tunjid.treenav.current +import com.tunjid.treenav.requireCurrent import com.tunjid.treenav.pop import com.tunjid.treenav.popToRoot import kotlinx.coroutines.CoroutineScope @@ -311,14 +311,8 @@ class AppState( MultiPaneDisplayState( panes = ThreePane.entries.toList(), navigationState = navigationState, - backStackTransform = { multiStackNav -> - multiStackNav.multiPaneDisplayBackstack() - }, - destinationTransform = { - it.current as? SampleDestination ?: throw IllegalArgumentException( - "MultiStackNav leaf node ${it.current} must be an AppDestination" - ) - }, + backStackTransform = MultiStackNav::multiPaneDisplayBackstack, + destinationTransform = MultiStackNav::requireCurrent, entryProvider = { destination -> when (destination) { SampleDestination.NavTabs.ChatRooms -> chatRoomPaneEntry() From dc162549f8b3b1203b55309eff9c28099b7cbf54 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 10 May 2025 12:05:52 -0400 Subject: [PATCH 4/6] Make PaneEntry for AvatarScreen transparent --- .../kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt index 632ce0e..9ffe5a4 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt @@ -18,6 +18,7 @@ package com.tunjid.demo.common.ui.avatar import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.coroutineScope @@ -50,6 +51,7 @@ fun avatarPaneEntry() = threePaneEntry( PaneScaffold( modifier = Modifier .fillMaxSize(), + containerColor = Color.Transparent, content = { AvatarScreen( movableSharedElementScope = this, From eff2ac42f6f4856bd18b7b1e4c843804a4975eeb Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 10 May 2025 12:35:06 -0400 Subject: [PATCH 5/6] Add tests for StackNavExt methods --- .../tunjid/treenav/compose/StackNavExtTest.kt | 92 +++++++++++++++++++ libraryVersion.properties | 8 +- 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt new file mode 100644 index 0000000..3d312a4 --- /dev/null +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt @@ -0,0 +1,92 @@ +/* + * 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 com.tunjid.treenav.MultiStackNav +import com.tunjid.treenav.Node +import com.tunjid.treenav.StackNav +import com.tunjid.treenav.backStack +import com.tunjid.treenav.requireCurrent +import com.tunjid.treenav.pop +import com.tunjid.treenav.push +import com.tunjid.treenav.reversedBackStackSequence +import com.tunjid.treenav.switch +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + + +class StackNavExtTest { + + @Test + fun testStackNavMultiPaneDisplayBackstack() { + + val subject = StackNav(name = "subject") + + val pushed = subject + .push(TestNode("A", children = listOf(TestNode("1")))) + .push(TestNode("B")) + .push(TestNode("C")) + .push(TestNode("D")) + .push(TestNode("E", children = listOf(TestNode("1"), TestNode("2")))) + .push(TestNode("F")) + + println(pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + distinctDestinations = true, + )) + assertEquals( + expected = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + distinctDestinations = true, + ), + actual = pushed.multiPaneDisplayBackstack() + ) + } + + @Test + fun testMultiStackNavMultiPaneDisplayBackstack() { + + val subject = MultiStackNav( + name = "subject", + stacks = listOf("0", "1", "2").map(::StackNav) + ) + + val pushed = subject + .push(TestNode("A")) + .push(TestNode("B")) + .push(TestNode("C")) + .switch(toIndex = 2) + .push(TestNode("D")) + .push(TestNode("E")) + .switch(toIndex = 1) + .push(TestNode("F")) + + assertEquals( + expected = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + distinctDestinations = true, + ), + actual = pushed.multiPaneDisplayBackstack() + ) + } +} \ No newline at end of file diff --git a/libraryVersion.properties b/libraryVersion.properties index 621d8e5..e854783 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,7 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.24 -strings_version=0.0.24 -compose_version=0.0.24 -compose-threepane_version=0.0.24 \ No newline at end of file +treenav_version=0.0.25 +strings_version=0.0.25 +compose_version=0.0.25 +compose-threepane_version=0.0.25 \ No newline at end of file From babe47b0688d40568ed97838df7504fff724a9a5 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 10 May 2025 12:41:53 -0400 Subject: [PATCH 6/6] Update gradle wrapper validation action --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2ef6541..85e9f1f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,6 +20,6 @@ jobs: java-version: '17' distribution: 'adopt' - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b + uses: gradle/actions/wrapper-validation@v3 - name: JVM tests run: ./gradlew jvmTest