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 new file mode 100644 index 0000000..8135b2e --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -0,0 +1,87 @@ +/* + * 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.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.tunjid.treenav.Node + +/** + * Scope that provides context about individual panes [Pane] in an [MultiPaneDisplay]. + */ +@Stable +interface MultiPaneDisplayScope { + + @Composable + fun Destination( + pane: Pane, + ) + + fun adaptationsIn( + pane: Pane, + ): Set + + fun destinationIn( + pane: Pane, + ): Destination? +} + +/** + * A Display that provides the following for each + * navigation [Destination] that shows up in its panes: + * + * - A single [SaveableStateHolder] for each navigation [Destination] that shows up in its panes. + * [SaveableStateHolder.SaveableStateProvider] is keyed on the [Destination]s [Node.id]. + * + * - A [ViewModelStoreOwner] for each [Destination] via [LocalViewModelStoreOwner]. + * Once present in the navigation tree, a [Destination] will always use the same + * [ViewModelStoreOwner], regardless of where in the tree it is, until its is removed from the tree. + * [Destination]s are unique based on their [Node.id]. + * + * - A [LifecycleOwner] for each [Destination] via [LocalLifecycleOwner]. This [LifecycleOwner] + * follows the [Lifecycle] of its immediate parent, unless it is animating out or placed in the + * backstack. This is defined by [PaneScope.isActive], which is a function of the backing + * [AnimatedContent] for each [Pane] displayed and if the current [Destination] + * matches [MultiPaneDisplayScope.destinationIn] in the visible [Pane]. + * + * @param state the driving [MultiPaneDisplayState] that applies adaptive semantics and + * transforms for each navigation destination shown in the [MultiPaneDisplay]. + */ +@Composable +fun MultiPaneDisplay( + state: MultiPaneDisplayState, + modifier: Modifier = Modifier, + content: @Composable MultiPaneDisplayScope.() -> Unit, +) { + Box( + modifier = modifier + ) { + SlottedMultiPaneDisplayScope( + 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 new file mode 100644 index 0000000..072c299 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -0,0 +1,145 @@ +/* + * 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.State +import androidx.compose.runtime.derivedStateOf +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.transforms.CompoundTransform +import com.tunjid.treenav.compose.transforms.DestinationTransform +import com.tunjid.treenav.compose.transforms.PaneTransform +import com.tunjid.treenav.compose.transforms.RenderTransform +import com.tunjid.treenav.compose.transforms.Transform + +/** + * Class for configuring a [MultiPaneDisplay] for selecting, adapting and placing navigation + * destinations into different panes from an arbitrary [navigationState]. + * + * @param panes a list of panes that is possible to show in the [MultiPaneDisplay] in all + * possible configurations. The panes should consist of enum class instances, or a sealed class + * hierarchy of kotlin objects. + * @param navigationState the navigation state to be adapted into various panes. + * @param backStackTransform a transform to read the back stack of the navigation state. + * @param destinationTransform a transform of the [navigationState] to its current destination. + * @param panesToDestinationsTransform provides the strategy used to adapt the current + * [Destination] to the panes available. + * @param renderTransform the transform used to render a [Destination] in its pane. + */ +class MultiPaneDisplayState internal constructor( + val panes: List, + val navigationState: State, + val backStackTransform: (NavigationState) -> List, + 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 + * showing different navigation destinations into different panes from an arbitrary + * [navigationState]. + * + * @param panes a list of panes that is possible to show in the [MultiPaneDisplay] in all + * possible configurations. The panes should consist of enum class instances, or a sealed class + * hierarchy of kotlin objects. + * @param navigationState the navigation state to be adapted into various panes. + * @param backStackTransform a transform to read the back stack of the navigation state. + * @param destinationTransform a transform of the [navigationState] to its current destination. + * @param entryProvider provides the [Transform]s and content needed to render + * a [Destination] in its pane. + * @param transforms a list of transforms applied to every [Destination] before it is + * rendered in its pane. Order matters; they are applied from last to first. + */ +fun MultiPaneDisplayState( + panes: List, + navigationState: State, + backStackTransform: (NavigationState) -> List, + destinationTransform: (NavigationState) -> Destination, + entryProvider: (Destination) -> PaneEntry, + transforms: List>, +) = transforms.fold( + initial = MultiPaneDisplayState( + panes = panes, + navigationState = navigationState, + backStackTransform = backStackTransform, + destinationTransform = destinationTransform, + panesToDestinationsTransform = { destination -> + entryProvider(destination).paneTransform(destination) + }, + renderTransform = { destination -> + val nav = entryProvider(destination) + with(nav.renderTransform) { + Render( + destination = destination, + previousTransform = nav.content, + ) + } + } + ), + operation = MultiPaneDisplayState::plus +) + +private operator fun + MultiPaneDisplayState.plus( + transform: Transform, +): MultiPaneDisplayState = + if (transform is CompoundTransform) transform.transforms.fold( + initial = this, + operation = MultiPaneDisplayState::plus, + ) + else MultiPaneDisplayState( + panes = panes, + navigationState = navigationState, + backStackTransform = backStackTransform, + destinationTransform = when (transform) { + is DestinationTransform -> { destination -> + transform.toDestination( + navigationState = destination, + previousTransform = destinationTransform + ) + } + + else -> destinationTransform + }, + panesToDestinationsTransform = when (transform) { + is PaneTransform -> { destination -> + transform.toPanesAndDestinations( + destination = destination, + previousTransform = panesToDestinationsTransform, + ) + } + + else -> panesToDestinationsTransform + }, + renderTransform = when (transform) { + is RenderTransform -> { destination -> + with(transform) { + Render( + destination = destination, + previousTransform = renderTransform, + ) + } + } + + else -> renderTransform + }, + ) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt new file mode 100644 index 0000000..7caeea5 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt @@ -0,0 +1,17 @@ +package com.tunjid.treenav.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.transforms.RenderTransform + +/** + * Provides the logic used to select, configure and place a navigation [Destination] for each + * pane [Pane] for the current active navigation [Destination]. + */ +@Stable +class PaneEntry( + internal val renderTransform: RenderTransform, + internal val paneTransform: @Composable (Destination) -> Map, + internal val content: @Composable PaneScope.(Destination) -> Unit, +) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt index d245ff0..acaf5ef 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt @@ -91,7 +91,7 @@ internal data class SlotPaneState( ) : PaneState /** - * A spot taken by an [PaneStrategy] that may be moved in from pane to pane. + * A spot taken by an [PaneEntry] that may be moved in from pane to pane. */ @JvmInline internal value class Slot internal constructor(val index: Int) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneStrategy.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneStrategy.kt deleted file mode 100644 index ce23829..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneStrategy.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.tunjid.treenav.compose - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import com.tunjid.treenav.Node - -/** - * Provides the logic used to select, configure and place a navigation [Destination] for each - * pane [Pane] for the current active navigation [Destination]. - */ -@Stable -class PaneStrategy internal constructor( - val transitions: PaneScope.() -> PaneScope.Transitions, - /** - * Defines what route to show in the secondary panel alongside this route - */ - val paneMapper: @Composable (Destination) -> Map, - val render: @Composable PaneScope.(Destination) -> Unit -) - -/** - * Allows for defining the logic used to select, configure and place a navigation - * [Destination] for each pane [Pane] for the current active navigation [Destination]. - * - * @param transitions the transitions to run within each [PaneScope]. - * @param paneMapping provides the mapping of panes to destinations for a given destination [Destination]. - * @param render defines the Composable rendered for each destination - * in a given [PaneScope]. - */ -fun paneStrategy( - transitions: PaneScope.() -> PaneScope.Transitions = { NoTransition }, - paneMapping: @Composable (Destination) -> Map = { emptyMap() }, - render: @Composable PaneScope.(Destination) -> Unit -) = PaneStrategy( - paneMapper = paneMapping, - transitions = transitions, - render = render -) - -private val NoTransition = PaneScope.Transitions( - enter = EnterTransition.None, - exit = ExitTransition.None, -) - -/** - * Creates a new [PaneStrategy] from this by conditionally overriding parts of it. - * - * @see paneStrategy - * - * @param transitions the transitions to run within each [PaneScope]. - * @param paneMapping provides the mapping of panes to destinations for a given - * navigation [Destination]. - * @param render defines the Composable rendered for each destination - * in a given [PaneScope]. - */ -fun PaneStrategy.delegated( - transitions: PaneScope.() -> PaneScope.Transitions = this.transitions, - paneMapping: @Composable (Destination) -> Map = this.paneMapper, - render: @Composable PaneScope.(Destination) -> Unit = this.render -) = paneStrategy( - transitions = transitions, - paneMapping = paneMapping, - render = render -) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavHost.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavHost.kt deleted file mode 100644 index bb89cd8..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavHost.kt +++ /dev/null @@ -1,46 +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.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.tunjid.treenav.Node - -/** - * Creates a navigation host for destinations [Destination] that can show up - * in arbitrary panes [Pane]. - * - * @param state the [PanedNavHostState] producing the [PanedNavHostScope] that provides - * context about the panes in [PanedNavHost]. - * @param modifier The modifier to be applied to the layout. - * @param content [PanedNavHostScope] receiving lambda allowing for placing each pane in its - * appropriate slot. - * - */ -@Composable -fun PanedNavHost( - state: PanedNavHostState, - modifier: Modifier = Modifier, - content: @Composable PanedNavHostScope.() -> Unit -) { - Box( - modifier = modifier - ) { - state.scope().content() - } -} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavHostConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavHostConfiguration.kt deleted file mode 100644 index 03b13c5..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PanedNavHostConfiguration.kt +++ /dev/null @@ -1,136 +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.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import com.tunjid.treenav.Node - -/** - * Class for configuring a [PanedNavHost] for selecting, adapting and placing navigation - * destinations into different panes from an arbitrary [navigationState]. - * - * @param navigationState the navigation state to be adapted into various panes. - * @param destinationTransform a transform of the [navigationState] to its current destination. - * @param strategyTransform provides the strategy used to adapt the current destination to the - * panes available. - */ -@Stable -class PanedNavHostConfiguration internal constructor( - val navigationState: State, - val destinationTransform: (NavigationState) -> Destination, - val strategyTransform: (destination: Destination) -> PaneStrategy -) { - internal val currentDestination: State = derivedStateOf { - destinationTransform(navigationState.value) - } -} - -/** - * Provides an [PanedNavHostConfiguration] for configuring an [PanedNavHost] for - * showing different navigation destinations into different panes from an arbitrary - * [navigationState]. - * - * @param navigationState the navigation state to with destinations [Destination] various - * panes [Pane]. - * @param destinationTransform a transform of the [navigationState] to its current destination. - * It is read inside a [derivedStateOf] block, so reads of snapshot - * state objects will be observed. - * @param strategyTransform provides the strategy used to adapt the current destination to the - * panes available. - */ -fun panedNavHostConfiguration( - navigationState: State, - destinationTransform: (NavigationState) -> Destination, - strategyTransform: (destination: Destination) -> PaneStrategy -) = PanedNavHostConfiguration( - navigationState = navigationState, - destinationTransform = destinationTransform, - strategyTransform = strategyTransform, -) - -/** - * Creates a new [PanedNavHost] by delegating to [this] and rendering destinations into different panes. - * - * @param destinationTransform a transform of [PanedNavHostConfiguration.navigationState] - * to its current destination. It is read inside a [derivedStateOf] block, so reads of snapshot - * state objects will be observed. - * @param strategyTransform provides the strategy used to adapt the current destination to the - * panes available. - */ -fun PanedNavHostConfiguration< - Pane, - NavigationState, - Destination - >.delegated( - destinationTransform: (NavigationState) -> Destination = this@delegated.destinationTransform, - strategyTransform: (destination: Destination) -> PaneStrategy -) = panedNavHostConfiguration( - navigationState = this@delegated.navigationState, - destinationTransform = destinationTransform, - strategyTransform = strategyTransform, -) - -/** - * The current destination in a given [paneScope]. - */ -@Composable -internal fun PanedNavHostConfiguration< - Pane, - *, - Destination - >.Destination( - paneScope: PaneScope -) { - val current = remember(paneScope.paneState.currentDestination) { - paneScope.paneState.currentDestination - } ?: return - with(strategyTransform(current)) { - val enterAndExit = transitions(paneScope) - with(paneScope) { - Box( - modifier = Modifier.animateEnterExit( - enter = enterAndExit.enter, - exit = enterAndExit.exit - ) - ) { - render(current) - } - } - } -} - -/** - * THe current pane mapping to use in the [PanedNavHost]. - */ -@Composable -internal fun PanedNavHostConfiguration< - Pane, - *, - Destination - >.paneMapping(): Map { - val current by currentDestination - return current.let { - strategyTransform(it).paneMapper(it) - } -} \ No newline at end of file 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 c8bd66d..a41bef7 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 @@ -1,29 +1,5 @@ package com.tunjid.treenav.compose -import com.tunjid.treenav.Node - -/** - * State providing details about data in each pane [Pane] it hosts. - */ -interface PanedNavigationState { - - /** - * The current [Destination] in this [pane]. - * @param pane the [Pane] to query. - */ - fun destinationFor( - pane: Pane, - ): Destination? - - /** - * Adaptations involving this [pane] after the last navigation state change. - * @param pane the affected [Pane]. - */ - fun adaptationsIn( - pane: Pane, - ): Set -} - /** * A description of the process that the layout undertook to adapt to the present * pane in its new configuration. diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SavedStatePanedNavHostState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SavedStatePanedNavHostState.kt deleted file mode 100644 index 3b00f8a..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SavedStatePanedNavHostState.kt +++ /dev/null @@ -1,330 +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.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -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.Order -import com.tunjid.treenav.compose.lifecycle.DestinationViewModelStoreCreator -import com.tunjid.treenav.compose.lifecycle.rememberDestinationLifecycleOwner -import com.tunjid.treenav.traverse - - -/** - * A host for adaptive navigation for panes [Pane] and destinations [Destination]. - */ -@Stable -interface PanedNavHostState { - - /** - * Creates the scope that provides context about individual panes [Pane] in a [PanedNavHost]. - */ - @Composable - fun scope(): PanedNavHostScope -} - -/** - * Scope that provides context about individual panes [Pane] in an [PanedNavHost]. - */ -@Stable -interface PanedNavHostScope { - - @Composable - fun Destination( - pane: Pane - ) - - fun adaptationsIn( - pane: Pane, - ): Set - - fun nodeFor( - pane: Pane, - ): Destination? -} - -/** - * An implementation of an [PanedNavHostState] that provides the following for each - * navigation [Destination] that shows up in its panes: - * - * - A single [SaveableStateHolder] for each navigation [Destination] that shows up in its panes. - * [SaveableStateHolder.SaveableStateProvider] is keyed on the [Destination]s [Node.id]. - * - * - A [ViewModelStoreOwner] for each [Destination] via [LocalViewModelStoreOwner]. - * Once present in the navigation tree, a [Destination] will always use the same - * [ViewModelStoreOwner], regardless of where in the tree it is, until its is removed from the tree. - * [Destination]s are unique based on their [Node.id]. - * - * - A [LifecycleOwner] for each [Destination] via [LocalLifecycleOwner]. This [LifecycleOwner] - * follows the [Lifecycle] of its immediate parent, unless it is animating out or placed in the - * backstack. This is defined by [PaneScope.isActive], which is a function of the backing - * [AnimatedContent] for each [Pane] displayed and if the current [Destination] - * matches [PanedNavHostScope.nodeFor] in the visible [Pane]. - * - * @param panes a list of panes that is possible to show in the [PanedNavHost] in all - * possible configurations. The panes should consist of enum class instances, or a sealed class - * hierarchy of kotlin objects. - * @param configuration the [PanedNavHostConfiguration] that applies adaptive semantics and - * strategies for each navigation destination shown in the [PanedNavHost]. - */ -@Stable -class SavedStatePanedNavHostState( - private val panes: List, - private val configuration: PanedNavHostConfiguration, -) : PanedNavHostState { - - @Composable - override fun scope(): PanedNavHostScope { - val navigationState by configuration.navigationState - val panesToNodes = configuration.paneMapping() - val saveableStateHolder = rememberPanedSaveableStateHolder() - - val panedContentScope = remember { - NavHostScope( - panes = panes, - navHostConfiguration = configuration, - initialPanesToNodes = panesToNodes, - saveableStateHolder = saveableStateHolder, - ) - } - - LaunchedEffect(navigationState, panesToNodes) { - panedContentScope.onNewNavigationState( - navigationState = navigationState, - panesToNodes = panesToNodes - ) - } - - return panedContentScope - } - - companion object { - @Stable - class NavHostScope internal constructor( - panes: List, - initialPanesToNodes: Map, - saveableStateHolder: SaveableStateHolder, - val navHostConfiguration: PanedNavHostConfiguration, - ) : PanedNavHostScope, 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 = navHostConfiguration.navigationState.value.backStackIds(), - ) - ) - - 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 nodeFor( - pane: Pane - ): Destination? = panedNavigationState.destinationFor(pane) - - internal fun onNewNavigationState( - navigationState: Node, - panesToNodes: Map, - ) { - updateAdaptiveNavigationState { - adaptTo( - slots = slots.toSet(), - panesToNodes = panesToNodes, - backStackIds = navigationState.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) { - val destinationLifecycleOwner = rememberDestinationLifecycleOwner( - destination - ) - val destinationViewModelOwner = remember(destination.id) { - destinationViewModelStoreCreator - .viewModelStoreOwnerFor(destination) - } - - CompositionLocalProvider( - LocalLifecycleOwner provides destinationLifecycleOwner, - LocalViewModelStoreOwner provides destinationViewModelOwner, - ) { - SaveableStateProvider(destination.id) { - navHostConfiguration.Destination(paneScope = scope) - - 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 Node.backStackIds() = - mutableSetOf().apply { - traverse(Order.DepthFirst) { add(it.id) } - } - } -} - -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/SlotBasedPanedNavigationState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt index b2a548a..471ed00 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 @@ -49,7 +49,7 @@ internal data class SlotBasedPanedNavigationState( * A set of node ids that are animating out. */ val destinationIdsAnimatingOut: Set, -) : PanedNavigationState { +) { companion object { internal fun initial( slots: Collection, @@ -66,7 +66,7 @@ internal data class SlotBasedPanedNavigationState( } internal fun paneStateFor( - slot: Slot + slot: Slot, ): PaneState { val node = destinationFor(slot) val pane = node?.let(::paneFor) @@ -80,19 +80,19 @@ internal data class SlotBasedPanedNavigationState( } internal fun slotFor( - pane: Pane + pane: Pane, ): Slot? = destinationIdsToAdaptiveSlots[ panesToDestinations[pane]?.id ] private fun paneFor( - node: Node + node: Node, ): Pane? = panesToDestinations.firstNotNullOfOrNull { (pane, paneRoute) -> if (paneRoute?.id == node.id) pane else null } private fun destinationFor( - slot: Slot + slot: Slot, ): Destination? = destinationIdsToAdaptiveSlots.firstNotNullOfOrNull { (nodeId, nodeSlot) -> if (nodeSlot == slot) panesToDestinations.firstNotNullOfOrNull { (_, node) -> if (node?.id == nodeId) node @@ -101,12 +101,12 @@ internal data class SlotBasedPanedNavigationState( else null } - override fun destinationFor( - pane: Pane + fun destinationFor( + pane: Pane, ): Destination? = panesToDestinations[pane] - override fun adaptationsIn( - pane: Pane + fun adaptationsIn( + pane: Pane, ): Set { val swaps = swapAdaptations.filter { pane in it } return if (swaps.isEmpty()) when (panesToDestinations[pane]?.id) { @@ -206,7 +206,7 @@ internal fun SlotBasedPanedNavigationState SlotBasedPanedNavigationState.prune(): SlotBasedPanedNavigationState = copy( 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 new file mode 100644 index 0000000..2cf1464 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlottedMultiPaneDisplayScope.kt @@ -0,0 +1,275 @@ +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/configurations/AnimatePaneBoundsConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/AnimatePaneBoundsConfiguration.kt deleted file mode 100644 index 29fdc28..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/AnimatePaneBoundsConfiguration.kt +++ /dev/null @@ -1,72 +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.configurations - -import androidx.compose.animation.BoundsTransform -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.LookaheadScope -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.PanedNavHostConfiguration -import com.tunjid.treenav.compose.delegated -import com.tunjid.treenav.compose.utilities.AnimatedBoundsState -import com.tunjid.treenav.compose.utilities.AnimatedBoundsState.Companion.animateBounds -import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform - -/** - * A [PanedNavHostConfiguration] that animates the bounds of each [Pane] displayed within it. - * This is useful for scenarios where the panes move within a layout hierarchy to accommodate - * other panes. - * - * @param lookaheadScope the root [LookaheadScope] where the panes are rendered in. - * @param paneBoundsTransform a lambda providing the [BoundsTransform] for each [Pane]. - * @param shouldAnimatePane a lambda for toggling when the pane can be animated. It allows for - * skipping an animation in progress. - */ -@OptIn(ExperimentalSharedTransitionApi::class) -fun PanedNavHostConfiguration< - Pane, - NavigationState, - Destination - >.animatePaneBoundsConfiguration( - lookaheadScope: LookaheadScope, - paneBoundsTransform: PaneScope.() -> BoundsTransform = { DefaultBoundsTransform }, - shouldAnimatePane: PaneScope.() -> Boolean = { true }, -): PanedNavHostConfiguration = - delegated { navigationDestination -> - val originalStrategy = strategyTransform(navigationDestination) - originalStrategy.delegated( - render = render@{ paneDestination -> - Box( - modifier = Modifier.animateBounds( - state = remember { - AnimatedBoundsState( - lookaheadScope = lookaheadScope, - boundsTransform = paneBoundsTransform(), - inProgress = { shouldAnimatePane() } - ) - } - ) - ) { - originalStrategy.render(this@render, paneDestination) - } - } - ) - } \ 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 cae07b2..9ce21a6 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 @@ -21,7 +21,7 @@ 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.PanedNavHost +import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform /** @@ -129,7 +129,7 @@ fun MovableSharedElementScope.updatedMovableSharedElementOf( ) /** - * State for managing movable shared elements within a single [PanedNavHost]. + * State for managing movable shared elements within a single [MultiPaneDisplay]. */ @OptIn(ExperimentalSharedTransitionApi::class) @Stable diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 8b28abb..f7d5911 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -22,15 +22,17 @@ import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import com.tunjid.treenav.Node import com.tunjid.treenav.compose.Adaptation.Swap +import com.tunjid.treenav.compose.MultiPaneDisplay +import com.tunjid.treenav.compose.PaneEntry import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.PaneStrategy -import com.tunjid.treenav.compose.paneStrategy /** - * A [PaneStrategy] for apps that display up to 3 major panes as once. + * A [PaneEntry] for apps that display up to 3 major panes as once. * It also provides extra panes for transient content. */ enum class ThreePane { @@ -84,7 +86,7 @@ enum class ThreePane { } /** - * A strategy for selectively running animations in list detail flows. When: + * A [PaneEntry] for selectively running animations in [ThreePane] [MultiPaneDisplay]. When: * - A navigation destination moves between the [ThreePane.Primary] and [ThreePane.Secondary] * panes, the pane animations are not run to provide a seamless movement experience. * - A navigation destination moves between the [ThreePane.Primary] and @@ -95,46 +97,56 @@ enum class ThreePane { * @param paneMapping the mapping of panes to navigation destinations. * @param render the Composable for rendering the current destination. */ -fun threePaneListDetailStrategy( +fun threePaneEntry( enterTransition: PaneScope.() -> EnterTransition = { DefaultFadeIn }, exitTransition: PaneScope.() -> ExitTransition = { DefaultFadeOut }, paneMapping: @Composable (R) -> Map = { mapOf(ThreePane.Primary to it) }, - render: @Composable PaneScope.(R) -> Unit -): PaneStrategy = paneStrategy( - paneMapping = paneMapping, - transitions = { + render: @Composable PaneScope.(R) -> Unit, +) = PaneEntry( + paneTransform = paneMapping, + renderTransform = { destination, original -> val state = paneState - when (state.pane) { + val modifier = when (state.pane) { ThreePane.Primary, - ThreePane.Secondary -> when { + ThreePane.Secondary, + -> when { ThreePane.PrimaryToSecondary in state.adaptations || ThreePane.SecondaryToPrimary in state.adaptations - -> NoTransition + -> Modifier - else -> PaneScope.Transitions( - enter = enterTransition(), - exit = exitTransition() - ) + else -> Modifier + .animateEnterExit( + enter = enterTransition(), + exit = exitTransition() + ) } ThreePane.TransientPrimary -> when { - ThreePane.PrimaryToTransient in state.adaptations -> NoTransition + ThreePane.PrimaryToTransient in state.adaptations -> Modifier + + else -> Modifier + .animateEnterExit( + enter = enterTransition(), + exit = exitTransition() + ) + } - else -> PaneScope.Transitions( + else -> Modifier + .animateEnterExit( enter = enterTransition(), exit = exitTransition() ) - } - - else -> PaneScope.Transitions( - enter = enterTransition(), - exit = exitTransition() - ) } + Box( + modifier = modifier + ) { + original(destination) + } + }, - render = render + content = render ) private val RouteTransitionAnimationSpec: FiniteAnimationSpec = tween( diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/PredictiveBackConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/PredictiveBackConfiguration.kt deleted file mode 100644 index 6ae19ed..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/PredictiveBackConfiguration.kt +++ /dev/null @@ -1,73 +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.threepane.configurations - -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.PanedNavHostConfiguration -import com.tunjid.treenav.compose.delegated -import com.tunjid.treenav.compose.threepane.ThreePane - -/** - * An [PanedNavHostConfiguration] that moves the destination in a [ThreePane.Primary] pane, to - * to the [ThreePane.TransientPrimary] pane when a predictive back gesture is in progress. - * - * @param isPreviewingBack provides the state of the predictive back gesture. - * True if the gesture is ongoing. - * @param backPreviewTransform provides the [NavigationState] if the predictive back gesture - * were to be completed. - */ -inline fun PanedNavHostConfiguration< - ThreePane, - NavigationState, - Destination - >.predictiveBackConfiguration( - isPreviewingBack: State, - crossinline backPreviewTransform: NavigationState.() -> NavigationState, -): PanedNavHostConfiguration { - var lastPrimaryDestination by mutableStateOf(null) - return delegated( - destinationTransform = { navigationState -> - val current = destinationTransform(navigationState) - lastPrimaryDestination = current - if (isPreviewingBack.value) destinationTransform(navigationState.backPreviewTransform()) - else current - }, - strategyTransform = { navigationDestination -> - val originalStrategy = strategyTransform(navigationDestination) - originalStrategy.delegated( - paneMapping = paneMapper@{ navigationDestinationToMap -> - val originalMapping = originalStrategy.paneMapper(navigationDestinationToMap) - val isPreviewing by isPreviewingBack - if (!isPreviewing) return@paneMapper originalMapping - // Back is being previewed, therefore the original mapping is already for back. - // Pass the previous primary value into transient. - val transientDestination = checkNotNull(lastPrimaryDestination) { - "Attempted to show last destination without calling destination transform" - } - val paneMapping = strategyTransform(transientDestination) - .paneMapper(transientDestination) - val transient = paneMapping[ThreePane.Primary] - originalMapping + (ThreePane.TransientPrimary to transient) - } - ) - } - ) -} diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/ThreePaneAdaptiveConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/ThreePaneAdaptiveConfiguration.kt deleted file mode 100644 index b6087cd..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/ThreePaneAdaptiveConfiguration.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tunjid.treenav.compose.threepane.configurations - -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.PanedNavHostConfiguration -import com.tunjid.treenav.compose.delegated -import com.tunjid.treenav.compose.threepane.ThreePane - -/** - * An [PanedNavHostConfiguration] that selectively displays panes for a [ThreePane] layout - * based on the space available determined by the [windowWidthState]. - * - * @param windowWidthState provides the current width of the display in Dp. - */ -fun PanedNavHostConfiguration< - ThreePane, - NavigationState, - Destination - >.threePanedNavHostConfiguration( - windowWidthState: State, - secondaryPaneBreakPoint: State = mutableStateOf(SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP), - tertiaryPaneBreakPoint: State = mutableStateOf(TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP), -): PanedNavHostConfiguration = delegated { destination -> - val originalStrategy = strategyTransform(destination) - originalStrategy.delegated( - paneMapping = { navigationDestinationToMap -> - // Consider navigation state different if window size class changes - val windowWidthDp by windowWidthState - val originalMapping = originalStrategy.paneMapper(navigationDestinationToMap) - 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 - }, - ThreePane.Tertiary to originalMapping[ThreePane.Tertiary].takeIf { tertiaryDestination -> - tertiaryDestination?.id != primaryNode?.id - && windowWidthDp >= tertiaryPaneBreakPoint.value - }, - ) - } - ) -} - -private val SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP = 600.dp -private val TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP = 1200.dp \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt new file mode 100644 index 0000000..3651a30 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt @@ -0,0 +1,66 @@ +/* + * 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.threepane.transforms + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.compoundTransform + +/** + * An [Transform] that moves the current [Destination] in a [ThreePane.Primary] pane, to + * to the [ThreePane.TransientPrimary] pane when "back" is being previewed. + * + * @param isPreviewingBack provides the state of the predictive back gesture. + * True if the gesture is ongoing. + * @param navigationStateBackTransform provides the [NavigationState] if the app were to + * go "back". + */ +inline fun + backPreviewTransform( + isPreviewingBack: State, + crossinline navigationStateBackTransform: NavigationState.() -> NavigationState, +): Transform { + var lastPrimaryDestination by mutableStateOf(null) + + return compoundTransform( + destinationTransform = { navigationState, previousTransform -> + val previousDestination = previousTransform(navigationState) + lastPrimaryDestination = previousDestination + if (isPreviewingBack.value) previousTransform(navigationState.navigationStateBackTransform()) + else previousDestination + }, + paneTransform = paneTransform@{ destination, previousTransform -> + val previousMapping = previousTransform(destination) + val isPreviewing by isPreviewingBack + if (!isPreviewing) return@paneTransform previousMapping + // Back is being previewed, therefore the original mapping is already for back. + // Pass the previous primary value into transient. + val transientDestination = checkNotNull(lastPrimaryDestination) { + "Attempted to show last destination without calling destination transform" + } + val paneMapping = previousTransform(transientDestination) + val transient = paneMapping[ThreePane.Primary] + previousMapping + (ThreePane.TransientPrimary to transient) + } + ) +} + diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/MovableSharedElementConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt similarity index 74% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/MovableSharedElementConfiguration.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 9ba39cf..6345087 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/MovableSharedElementConfiguration.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -1,4 +1,4 @@ -package com.tunjid.treenav.compose.threepane.configurations +package com.tunjid.treenav.compose.threepane.transforms import androidx.compose.animation.BoundsTransform import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -11,51 +11,43 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember 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.PanedNavHost -import com.tunjid.treenav.compose.PanedNavHostConfiguration -import com.tunjid.treenav.compose.delegated -import com.tunjid.treenav.compose.moveablesharedelement.PanedMovableSharedElementScope +import com.tunjid.treenav.compose.transforms.RenderTransform +import com.tunjid.treenav.compose.transforms.Transform import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope +import com.tunjid.treenav.compose.moveablesharedelement.PanedMovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane /** - * An [PanedNavHostConfiguration] that applies semantics of movable shared elements to + * A [Transform] that applies semantics of movable shared elements to * [ThreePane] layouts. * * @param movableSharedElementHostState the host state for coordinating movable shared elements. - * There should be one instance of this per [PanedNavHost]. + * There should be one instance of this per [MultiPaneDisplay]. */ -fun PanedNavHostConfiguration< - ThreePane, - NavigationState, - Destination - >.threePanedMovableSharedElementConfiguration( +fun + threePanedMovableSharedElementTransform( movableSharedElementHostState: MovableSharedElementHostState, -): PanedNavHostConfiguration = - delegated { navigationDestination -> - val originalStrategy = strategyTransform(navigationDestination) - originalStrategy.delegated( - render = { paneDestination -> - val delegate = remember { - PanedMovableSharedElementScope( - paneScope = this, - movableSharedElementHostState = movableSharedElementHostState, - ) - } - delegate.paneScope = this +): Transform = + RenderTransform { destination, previousTransform -> + val delegate = remember { + PanedMovableSharedElementScope( + paneScope = this, + movableSharedElementHostState = movableSharedElementHostState, + ) + } + delegate.paneScope = this - val movableSharedElementScope = remember { - ThreePaneMovableSharedElementScope( - hostState = movableSharedElementHostState, - delegate = delegate, - ) - } + val movableSharedElementScope = remember { + ThreePaneMovableSharedElementScope( + hostState = movableSharedElementHostState, + delegate = delegate, + ) + } - originalStrategy.render(movableSharedElementScope, paneDestination) - }, - ) + previousTransform(movableSharedElementScope, destination) } fun PaneScope< @@ -89,7 +81,7 @@ private class ThreePaneMovableSharedElementScope( zIndexInOverlay: Float, clipInOverlayDuringTransition: OverlayClip, alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, - sharedElement: @Composable (T, Modifier) -> Unit + sharedElement: @Composable (T, Modifier) -> Unit, ): @Composable (T, Modifier) -> Unit = when (paneState.pane) { null -> throw IllegalArgumentException( "Shared elements may only be used in non null panes" @@ -127,7 +119,8 @@ private class ThreePaneMovableSharedElementScope( // In the other panes use the element as is ThreePane.Secondary, ThreePane.Tertiary, - ThreePane.Overlay -> alternateOutgoingSharedElement ?: sharedElement + ThreePane.Overlay, + -> alternateOutgoingSharedElement ?: sharedElement } } 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 new file mode 100644 index 0000000..4694971 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt @@ -0,0 +1,44 @@ +package com.tunjid.treenav.compose.threepane.transforms + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.transforms.PaneTransform +import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.threepane.ThreePane + +/** + * An [Transform] that selectively displays panes for a [ThreePane] layout + * based on the space available determined by the [windowWidthState]. + * + * @param windowWidthState provides the current width of the display in Dp. + */ +fun + threePanedAdaptiveTransform( + windowWidthState: State, + secondaryPaneBreakPoint: State = mutableStateOf(SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP), + 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 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 + }, + ThreePane.Tertiary to originalMapping[ThreePane.Tertiary].takeIf { tertiaryDestination -> + tertiaryDestination?.id != primaryNode?.id + && windowWidthDp >= tertiaryPaneBreakPoint.value + }, + ) + } + +private val SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP = 600.dp +private val TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP = 1200.dp \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/PaneModifierConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt similarity index 52% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/PaneModifierConfiguration.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt index 31d328d..0f5f970 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/PaneModifierConfiguration.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt @@ -14,37 +14,27 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.configurations +package com.tunjid.treenav.compose.transforms import androidx.compose.foundation.layout.Box import androidx.compose.ui.Modifier import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.PanedNavHostConfiguration -import com.tunjid.treenav.compose.delegated /** - * A [PanedNavHostConfiguration] that allows for centrally defining the [Modifier] for + * A [MultiPaneDisplayState] that allows for centrally defining the [Modifier] for * each [Pane] displayed within it. * * @param paneModifier a lambda for specifying the [Modifier] for each [Pane] in a [PaneScope]. */ -fun PanedNavHostConfiguration< - Pane, - NavigationState, - Destination - >.paneModifierConfiguration( +fun paneModifierTransform( paneModifier: PaneScope.() -> Modifier = { Modifier }, -): PanedNavHostConfiguration = - delegated { navigationDestination -> - val originalStrategy = strategyTransform(navigationDestination) - originalStrategy.delegated( - render = render@{ paneDestination -> - Box( - modifier = paneModifier() - ) { - originalStrategy.render(this@render, paneDestination) - } - } - ) +): Transform = + RenderTransform { destination, previousTransform -> + Box( + modifier = paneModifier() + ) { + previousTransform(destination) + } } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/Transforms.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/Transforms.kt new file mode 100644 index 0000000..a7078be --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/Transforms.kt @@ -0,0 +1,106 @@ +package com.tunjid.treenav.compose.transforms + +import androidx.compose.runtime.Composable +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.PaneScope +import com.tunjid.treenav.compose.MultiPaneDisplay +import com.tunjid.treenav.compose.MultiPaneDisplayState + +/** + * Provides APIs for adjusting what is presented in a [MultiPaneDisplay]. + */ +sealed interface Transform + +/** + * A [Transform] that allows for changing the current [Destination] in the [MultiPaneDisplay] + * sees without actually modifying the backing [NavigationState]. + */ +fun interface DestinationTransform + : Transform { + + /** + * Given a [NavigationState], provide the current [Destination] to show. The [Destination] + * returned must already exist in the back stack of the [MultiPaneDisplayState.navigationState]. + * + * @param navigationState the current navigation state. + * @param previousTransform a [Transform] that when invoked, returns the [Destination] that + * would have been shown pre-transform that can then be composed with new logic. + */ + fun toDestination( + navigationState: NavigationState, + previousTransform: (NavigationState) -> Destination, + ): Destination +} + +/** + * A [Transform] that allows for changing which [Destination] shows in which [Pane]. + */ +fun interface PaneTransform + : Transform { + + /** + * Given the current [Destination], provide what [Destination] to show in a [Pane]. + * Each [Destination] in the returned mapping must already exist in the + * back stack of the [MultiPaneDisplayState.navigationState]. + * + * @param destination the current [Destination] to display. + * @param previousTransform a [Transform] that when invoked, returns the pane to destination + * mapping for the current [Destination] pre-transform that can then be composed with new logic. + */ + @Composable + fun toPanesAndDestinations( + destination: Destination, + previousTransform: @Composable (Destination) -> Map, + ): Map +} + +/** + * A [Transform] that allows for the rendering semantics of a [Destination] in a given + * [PaneScope]. + */ +fun interface RenderTransform + : Transform { + + /** + * Given the current [Destination], and its [PaneScope], compose additional presentation + * logic. + * + * @param destination the current [Destination] to display in the provided [PaneScope]. + * @param previousTransform a [Transform] that when invoked, renders the [Destination] + * for the [PaneScope ]pre-transform that can then be composed with new logic. + */ + @Composable + fun PaneScope.Render( + destination: Destination, + previousTransform: @Composable PaneScope.(Destination) -> Unit, + ) +} + +internal class CompoundTransform( + destinationTransform: DestinationTransform?, + paneTransform: PaneTransform?, + renderTransform: RenderTransform?, +) : Transform { + val transforms = listOfNotNull( + destinationTransform, + paneTransform, + renderTransform, + ) +} + +/** + * Creates a transform that an aggregation of the transforms provided to it. + * + * @see DestinationTransform + * @see PaneTransform + * @see RenderTransform + */ +fun compoundTransform( + destinationTransform: DestinationTransform? = null, + paneTransform: PaneTransform? = null, + renderTransform: RenderTransform? = null, +): Transform = CompoundTransform( + destinationTransform = destinationTransform, + paneTransform = paneTransform, + renderTransform = renderTransform, +) \ 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 8bdc6ec..2998830 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt @@ -24,7 +24,7 @@ data class MultiStackNav( val name: String, val indexHistory: List = listOf(0), val currentIndex: Int = 0, - val stacks: List = listOf() + val stacks: List = listOf(), ) : Node { override val id: String get() = name override val children: List get() = stacks @@ -79,6 +79,36 @@ fun MultiStackNav.popToRoot(indexToPop: Int = currentIndex) = copy( } ) +/** + * Returns a sequence of each destination on the back stack for this [StackNav] as defined by + * [MultiStackNav.pop]. + * + * @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, +): Sequence = + if (!includeCurrentDestinationChildren && placeChildrenBeforeParent) throw IllegalArgumentException( + "Cannot place children nodes before the parent if children are not included" + ) + else generateSequence(this) { current -> + current.pop().takeUnless(current::equals) + } + .flatMap { nav -> + val parent = listOfNotNull(nav.current) + val children = nav.current + ?.children + ?.takeIf { includeCurrentDestinationChildren } + ?: emptyList() + + if (placeChildrenBeforeParent) children + parent + else parent + children + } + /** * Performs the given [operation] with the [StackNav] at [MultiStackNav.currentIndex] */ 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 6efef3d..19a4a20 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt @@ -21,7 +21,7 @@ package com.tunjid.treenav */ data class StackNav( val name: String, - override val children: List = listOf() + override val children: List = listOf(), ) : Node { override val id: String get() = name } @@ -55,6 +55,36 @@ fun StackNav.popToRoot() = copy( children = children.take(1) ) +/** + * Returns a sequence of each destination on the back stack for this [StackNav] as defined by + * [StackNav.pop]. + * + * @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, +): Sequence = + if (!includeCurrentDestinationChildren && placeChildrenBeforeParent) throw IllegalArgumentException( + "Cannot place children nodes before the parent if children are not included" + ) + else generateSequence(this) { current -> + current.pop().takeUnless(current::equals) + } + .flatMap { nav -> + val parent = listOfNotNull(nav.current) + val children = nav.current + ?.children + ?.takeIf { includeCurrentDestinationChildren } + ?: emptyList() + + if (placeChildrenBeforeParent) children + parent + else parent + children + } + /** * Indicates if there's a [Node] available to pop up to */ diff --git a/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt b/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt index a3a24d8..c279453 100644 --- a/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt +++ b/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt @@ -17,6 +17,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.flatten import com.tunjid.treenav.minus import com.tunjid.treenav.pop @@ -317,4 +318,71 @@ class MultiStackNavTest { .toSet() ) } + + @Test + fun testBackStack() { + val pushed = subject + .push(TestNode("A", children = listOf(TestNode("1")))) + .push(TestNode("B")) + .push(TestNode("C")) + .switch(toIndex = 2) + .push(TestNode("D")) + .push(TestNode("E", children = listOf(TestNode("1"), TestNode("2")))) + .switch(toIndex = 1) + .push(TestNode("F")) + + assertEquals( + listOf( + TestNode("F"), + TestNode("E", children = listOf(TestNode("1"), TestNode("2"))), + TestNode("D"), + TestNode("C"), + TestNode("B"), + TestNode("A", children = listOf(TestNode("1"))), + ), + pushed.backStack( + includeCurrentDestinationChildren = false, + placeChildrenBeforeParent = false, + ) + .toList() + ) + + assertEquals( + expected = listOf( + TestNode("F"), + TestNode("E", children = listOf(TestNode("1"), TestNode("2"))), + TestNode("1"), + TestNode("2"), + TestNode("D"), + TestNode("C"), + TestNode("B"), + TestNode("A", children = listOf(TestNode("1"))), + TestNode("1"), + ), + actual = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = false, + ) + .toList() + ) + + assertEquals( + expected = listOf( + TestNode("F"), + TestNode("1"), + TestNode("2"), + TestNode("E", children = listOf(TestNode("1"), TestNode("2"))), + TestNode("D"), + TestNode("C"), + TestNode("B"), + TestNode("1"), + TestNode("A", children = listOf(TestNode("1"))), + ), + actual = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .toList() + ) + } } diff --git a/library/treenav/src/commonTest/kotlin/StackNavTest.kt b/library/treenav/src/commonTest/kotlin/StackNavTest.kt index a3113ff..09043e0 100644 --- a/library/treenav/src/commonTest/kotlin/StackNavTest.kt +++ b/library/treenav/src/commonTest/kotlin/StackNavTest.kt @@ -17,6 +17,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.current import com.tunjid.treenav.flatten import com.tunjid.treenav.minus @@ -44,7 +45,10 @@ import kotlin.test.assertTrue * 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 } @@ -152,4 +156,69 @@ class StackNavTest { .children ) } + + @Test + fun testBackStack() { + 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")) + + assertEquals( + listOf( + TestNode("F"), + TestNode("E", children = listOf(TestNode("1"), TestNode("2"))), + TestNode("D"), + TestNode("C"), + TestNode("B"), + TestNode("A", children = listOf(TestNode("1"))), + ), + pushed.backStack( + includeCurrentDestinationChildren = false, + placeChildrenBeforeParent = false, + ) + .toList() + ) + + assertEquals( + expected = listOf( + TestNode("F"), + TestNode("E", children = listOf(TestNode("1"), TestNode("2"))), + TestNode("1"), + TestNode("2"), + TestNode("D"), + TestNode("C"), + TestNode("B"), + TestNode("A", children = listOf(TestNode("1"))), + TestNode("1"), + ), + actual = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = false, + ) + .toList() + ) + + assertEquals( + expected = listOf( + TestNode("F"), + TestNode("1"), + TestNode("2"), + TestNode("E", children = listOf(TestNode("1"), TestNode("2"))), + TestNode("D"), + TestNode("C"), + TestNode("B"), + TestNode("1"), + TestNode("A", children = listOf(TestNode("1"))), + ), + actual = pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .toList() + ) + } } 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 a0659aa..c536f14 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 @@ -18,9 +18,12 @@ package com.tunjid.demo.common.ui import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.animateBounds import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation @@ -48,7 +51,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -67,7 +69,7 @@ import com.tunjid.composables.backpreview.BackPreviewState 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.rememberPanedNavHostState +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.data.NavigationRepository @@ -75,18 +77,17 @@ 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.treenav.MultiStackNav -import com.tunjid.treenav.compose.PanedNavHost -import com.tunjid.treenav.compose.PanedNavHostConfiguration -import com.tunjid.treenav.compose.PanedNavHostScope -import com.tunjid.treenav.compose.SavedStatePanedNavHostState -import com.tunjid.treenav.compose.configurations.animatePaneBoundsConfiguration -import com.tunjid.treenav.compose.configurations.paneModifierConfiguration +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.panedNavHostConfiguration import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.configurations.predictiveBackConfiguration -import com.tunjid.treenav.compose.threepane.configurations.threePanedMovableSharedElementConfiguration -import com.tunjid.treenav.compose.threepane.configurations.threePanedNavHostConfiguration +import com.tunjid.treenav.compose.threepane.transforms.backPreviewTransform +import com.tunjid.treenav.compose.threepane.transforms.threePanedAdaptiveTransform +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.pop import com.tunjid.treenav.popToRoot @@ -128,50 +129,57 @@ fun App( canAnimatePanes = !interactingWithPanes } - PanedNavHost( + MultiPaneDisplay( modifier = Modifier .fillMaxSize(), - state = appState.rememberPanedNavHostState { - this - .threePanedNavHostConfiguration( - windowWidthState = derivedStateOf { - appState.splitLayoutState.size + state = appState.rememberMultiPaneDisplayState( + listOf( + threePanedAdaptiveTransform( + windowWidthState = remember { + derivedStateOf { + appState.splitLayoutState.size + } } - ) - .predictiveBackConfiguration( - isPreviewingBack = derivedStateOf { - appState.isPreviewingBack + ), + backPreviewTransform( + isPreviewingBack = remember { + derivedStateOf { + appState.isPreviewingBack + } }, - backPreviewTransform = MultiStackNav::pop, - ) - .threePanedMovableSharedElementConfiguration( + navigationStateBackTransform = MultiStackNav::pop, + ), + threePanedMovableSharedElementTransform( movableSharedElementHostState = movableSharedElementHostState - ) - .animatePaneBoundsConfiguration( - lookaheadScope = this@SharedTransitionScope, - shouldAnimatePane = { - when (paneState.pane) { - ThreePane.Primary, - ThreePane.TransientPrimary, - ThreePane.Secondary, - ThreePane.Tertiary -> canAnimatePanes + ), + 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 -> false + null, + ThreePane.Overlay, + -> snap() + } } - } - ) - .paneModifierConfiguration { - if (paneState.pane == ThreePane.TransientPrimary) Modifier + ) + if (paneState.pane == ThreePane.TransientPrimary) modifier .fillMaxSize() .backPreview(appState.backPreviewState) .background(backPreviewSurfaceColor, RoundedCornerShape(16.dp)) - else Modifier + else modifier .fillMaxSize() } - }, + ) + ), ) { - appState.panedNavHostScope = this + appState.displayScope = this appState.splitLayoutState.visibleCount = appState.filteredPaneOrder.size SplitLayout( state = appState.splitLayoutState, @@ -262,15 +270,13 @@ fun InteractionSource.isActive(): Boolean { @Stable class AppState( - private val navigationRepository: NavigationRepository = NavigationRepository + private val navigationRepository: NavigationRepository = NavigationRepository, ) { private val navigationState = mutableStateOf( navigationRepository.navigationStateFlow.value ) - private val panedNavHostConfiguration = sampleAppNavHostConfiguration( - navigationState - ) + private val paneInteractionSourceList = mutableStateListOf() private val paneRenderOrder = listOf( ThreePane.Tertiary, @@ -293,12 +299,12 @@ class AppState( internal val isPreviewingBack get() = !backPreviewState.progress.isNaN() - internal var panedNavHostScope by mutableStateOf?>( + internal var displayScope by mutableStateOf?>( null ) val filteredPaneOrder: List by derivedStateOf { - paneRenderOrder.filter { panedNavHostScope?.nodeFor(it) != null } + paneRenderOrder.filter { displayScope?.destinationIn(it) != null } } fun setTab(destination: SampleDestination.NavTabs) { @@ -325,17 +331,38 @@ class AppState( companion object { @Composable - fun AppState.rememberPanedNavHostState( - configurationBlock: PanedNavHostConfiguration< - ThreePane, - MultiStackNav, - SampleDestination - >.() -> PanedNavHostConfiguration - ): SavedStatePanedNavHostState { - val panedNavHostState = remember { - SavedStatePanedNavHostState( + fun AppState.rememberMultiPaneDisplayState( + transforms: List>, + ): MultiPaneDisplayState { + val displayState = remember { + MultiPaneDisplayState( panes = ThreePane.entries.toList(), - configuration = panedNavHostConfiguration.configurationBlock(), + navigationState = navigationState, + backStackTransform = { multiStackNav -> + multiStackNav.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + ) + .filterIsInstance() + .toList() + }, + destinationTransform = { + it.current as? SampleDestination ?: throw IllegalArgumentException( + "MultiStackNav leaf node ${it.current} must be an AppDestination" + ) + }, + entryProvider = { destination -> + when (destination) { + SampleDestination.NavTabs.ChatRooms -> chatRoomPaneStrategy() + + SampleDestination.NavTabs.Me -> mePaneStrategy() + + is SampleDestination.Chat -> chatPaneStrategy() + + is SampleDestination.Profile -> profilePaneStrategy() + } + }, + transforms = transforms, ) } DisposableEffect(Unit) { @@ -346,32 +373,10 @@ class AppState( } onDispose { job.cancel() } } - return panedNavHostState + return displayState } } } -private fun sampleAppNavHostConfiguration( - multiStackNavState: State -) = panedNavHostConfiguration( - navigationState = multiStackNavState, - destinationTransform = { multiStackNav -> - multiStackNav.current as? SampleDestination ?: throw IllegalArgumentException( - "MultiStackNav leaf node ${multiStackNav.current} must be an AppDestination" - ) - }, - strategyTransform = { destination -> - when (destination) { - SampleDestination.NavTabs.ChatRooms -> chatRoomPaneStrategy() - - SampleDestination.NavTabs.Me -> mePaneStrategy() - - is SampleDestination.Chat -> chatPaneStrategy() - - is SampleDestination.Profile -> profilePaneStrategy() - } - } -) - private val PaneSeparatorActiveWidthDp = 56.dp private val PaneSeparatorTouchTargetWidthDp = 16.dp \ No newline at end of file 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 452a8ad..a175546 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 @@ -26,10 +26,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.configurations.requireThreePaneMovableSharedElementScope -import com.tunjid.treenav.compose.threepane.threePaneListDetailStrategy +import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope +import com.tunjid.treenav.compose.threepane.threePaneEntry -fun chatPaneStrategy() = threePaneListDetailStrategy( +fun chatPaneStrategy() = threePaneEntry( paneMapping = { destination -> mapOf( ThreePane.Primary to destination, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt index 273b8ba..d5541c3 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 @@ -24,11 +24,11 @@ import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.data.ChatsRepository import com.tunjid.demo.common.ui.data.SampleDestination -import com.tunjid.treenav.compose.threepane.configurations.requireThreePaneMovableSharedElementScope -import com.tunjid.treenav.compose.threepane.threePaneListDetailStrategy +import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope +import com.tunjid.treenav.compose.threepane.threePaneEntry fun chatRoomPaneStrategy( -) = threePaneListDetailStrategy( +) = threePaneEntry( render = { val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { 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 a16fccb..594fa6f 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 @@ -23,11 +23,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.profile.ProfileScreen import com.tunjid.demo.common.ui.profile.ProfileViewModel -import com.tunjid.treenav.compose.threepane.configurations.requireThreePaneMovableSharedElementScope -import com.tunjid.treenav.compose.threepane.threePaneListDetailStrategy +import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope +import com.tunjid.treenav.compose.threepane.threePaneEntry fun mePaneStrategy( -) = threePaneListDetailStrategy( +) = threePaneEntry( render = { val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { 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 de8a569..c229e19 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 @@ -25,10 +25,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.configurations.requireThreePaneMovableSharedElementScope -import com.tunjid.treenav.compose.threepane.threePaneListDetailStrategy +import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope +import com.tunjid.treenav.compose.threepane.threePaneEntry -fun profilePaneStrategy() = threePaneListDetailStrategy( +fun profilePaneStrategy() = threePaneEntry( paneMapping = { destination -> check(destination is SampleDestination.Profile) mapOf(