From e8ba353cde899fdaa217e5084700fa4d7ad90a2f Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Wed, 21 May 2025 11:03:46 -0400 Subject: [PATCH 01/78] Copied classes for nav3 scenes --- gradle/libs.versions.toml | 2 + library/compose/build.gradle.kts | 1 + ...lNavigationEventDispatcherOwner.android.kt | 26 ++ .../compose/navigation3/ui/DialogScene.kt | 84 ++++ .../ui/LocalNavAnimatedContentScope.kt | 39 ++ .../ui/LocalNavigationEventDispatcherOwner.kt | 53 +++ .../compose/navigation3/ui/NavDisplay.kt | 430 ++++++++++++++++++ .../navigation3/ui/NavigationEventHandler.kt | 160 +++++++ .../compose/navigation3/ui/OverlayScene.kt | 40 ++ .../treenav/compose/navigation3/ui/Scene.kt | 88 ++++ .../ui/SceneSetupNavEntryDecorator.kt | 110 +++++ .../compose/navigation3/ui/SceneStrategy.kt | 54 +++ .../compose/navigation3/ui/SinglePaneScene.kt | 51 +++ ...ansitionAwareLifecycleNavEntryDecorator.kt | 96 ++++ ...LocalNavigationEventDispatcherOwner.jvm.kt | 24 + ...alNavigationEventDispatcherOwner.native.kt | 24 + 16 files changed, 1282 insertions(+) create mode 100644 library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.android.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt create mode 100644 library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt create mode 100644 library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf60892..a5d7865 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ androidxBenchmark = "1.3.4" androidxCore = "1.16.0" androidxCollection = "1.5.0" androidxCompose = "1.7.0" +androidxNavigationEvent = "1.0.0-alpha01" androidxPaging = "3.3.2" androidxSavedState = "1.3.0-alpha07" androidxTestCore = "1.6.1" @@ -39,6 +40,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } +androidx-navigation-event = { group = "androidx.navigationevent", name = "navigationevent", version.ref = "androidxNavigationEvent" } jetbrains-compose-animation = { group = "org.jetbrains.compose.animation", name = "animation", version.ref = "jetbrainsCompose" } jetbrains-compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "jetbrainsCompose" } diff --git a/library/compose/build.gradle.kts b/library/compose/build.gradle.kts index c7c7f15..29dd82b 100644 --- a/library/compose/build.gradle.kts +++ b/library/compose/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { implementation(project(":library:treenav")) implementation(libs.androidx.collection) + implementation(libs.androidx.navigation.event) implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.foundation) diff --git a/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.android.kt b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.android.kt new file mode 100644 index 0000000..ec43f7c --- /dev/null +++ b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.android.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalView +import androidx.navigationevent.NavigationEventDispatcherOwner +import androidx.navigationevent.findViewTreeNavigationEventDispatcherOwner + +@Composable +internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = + LocalView.current.findViewTreeNavigationEventDispatcherOwner() \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt new file mode 100644 index 0000000..6d7290e --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.navigation3.NavEntry + +/** An [OverlayScene] that renders an [entry] within a [Dialog]. */ +internal class DialogScene( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val dialogProperties: DialogProperties, + private val onBack: (count: Int) -> Unit, +) : OverlayScene { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + Dialog(onDismissRequest = { onBack(1) }, properties = dialogProperties) { + entry.content.invoke(entry.key) + } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [dialog] to their [NavEntry.metadata] + * within a [Dialog] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +internal class DialogSceneStrategy() : SceneStrategy { + @Composable + public override fun calculateScene( + entries: List>, + onBack: (count: Int) -> Unit, + ): Scene? { + val lastEntry = entries.lastOrNull() + val dialogProperties = lastEntry?.metadata?.get(DIALOG_KEY) as? DialogProperties + return dialogProperties?.let { properties -> + DialogScene( + key = lastEntry.key, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + dialogProperties = properties, + onBack = onBack, + ) + } + } + + public companion object { + /** + * Function to be called on the [NavEntry.metadata] to mark this entry as something that + * should be displayed within a [Dialog]. + * + * @param dialogProperties properties that should be passed to the containing [Dialog]. + */ + public fun dialog( + dialogProperties: DialogProperties = DialogProperties() + ): Map = mapOf(DIALOG_KEY to dialogProperties) + + internal const val DIALOG_KEY = "dialog" + } +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt new file mode 100644 index 0000000..92bac38 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +/** + * Local provider of [AnimatedContentScope] to [androidx.navigation3.runtime.NavEntry.content]. + * + * This does not have a default value since the AnimatedContentScope is provided at runtime by + * AnimatedContent. + * + * @sample androidx.navigation3.ui.samples.SceneNavSharedElementSample + */ +public val LocalNavAnimatedContentScope: ProvidableCompositionLocal = + compositionLocalOf { + // no default, we need an AnimatedContent to get the AnimatedContentScope + throw IllegalStateException( + "Unexpected access to LocalNavAnimatedContentScope. You should only " + + "access LocalNavAnimatedContentScope inside a NavEntry passed " + + "to NavDisplay." + ) + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt new file mode 100644 index 0000000..4332b90 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner + +/** The CompositionLocal containing the current [NavigationEventDispatcher]. */ +internal object LocalNavigationEventDispatcherOwner { + private val LocalNavigationEventDispatcherOwner = + compositionLocalOf { null } + + /** + * Returns current composition local value for the owner or `null` if one has not been provided + * nor is one available via [findViewTreeNavigationEventDispatcherOwner] on the current + * [androidx.compose.ui.platform.LocalView]. + */ + val current: NavigationEventDispatcherOwner? + @Composable + get() = + LocalNavigationEventDispatcherOwner.current + ?: findViewTreeNavigationEventDispatcherOwner() + + /** + * Associates a [LocalNavigationEventDispatcherOwner] key to a value in a call to + * [CompositionLocalProvider]. + */ + internal infix fun provides( + navigationEventDispatcherOwner: NavigationEventDispatcherOwner + ): ProvidedValue { + return LocalNavigationEventDispatcherOwner.provides(navigationEventDispatcherOwner) + } +} + +@Composable +internal expect fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt new file mode 100644 index 0000000..93de710 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt @@ -0,0 +1,430 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.collection.mutableObjectFloatMapOf +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastForEachReversed +import com.tunjid.treenav.compose.navigation3.DecoratedNavEntryProvider +import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.decorators.rememberSavedStateNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.DEFAULT_TRANSITION_DURATION_MILLISECOND +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.POP_TRANSITION_SPEC +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.PREDICTIVE_POP_TRANSITION_SPEC +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.TRANSITION_SPEC +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +/** Object that indicates the features that can be handled by the [NavDisplay] */ +internal object NavDisplay { + /** + * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that the content + * should be animated using the provided [ContentTransform]. + */ + public fun transitionSpec( + transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? + ): Map = mapOf(TRANSITION_SPEC to transitionSpec) + + /** + * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that, when + * popping from backstack, the content should be animated using the provided [ContentTransform]. + */ + public fun popTransitionSpec( + popTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? + ): Map = mapOf(POP_TRANSITION_SPEC to popTransitionSpec) + + /** + * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that, when + * popping from backstack using a Predictive back gesture, the content should be animated using + * the provided [ContentTransform]. + */ + public fun predictivePopTransitionSpec( + predictivePopTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? + ): Map = mapOf(PREDICTIVE_POP_TRANSITION_SPEC to predictivePopTransitionSpec) + + public val defaultPredictivePopTransitionSpec: + AnimatedContentTransitionScope<*>.() -> ContentTransform = + { + ContentTransform( + fadeIn( + spring( + dampingRatio = 1.0f, // reflects material3 motionScheme.defaultEffectsSpec() + stiffness = 1600.0f, // reflects material3 motionScheme.defaultEffectsSpec() + ) + ), + scaleOut(targetScale = 0.7f), + ) + } + + internal const val TRANSITION_SPEC = "transitionSpec" + internal const val POP_TRANSITION_SPEC = "popTransitionSpec" + internal const val PREDICTIVE_POP_TRANSITION_SPEC = "predictivePopTransitionSpec" + + internal const val DEFAULT_TRANSITION_DURATION_MILLISECOND = 700 +} + +/** + * A nav display that renders and animates between different [Scene]s, each of which can render one + * or more [NavEntry]s. + * + * The [Scene]s are calculated with the given [SceneStrategy], which may be an assembled delegated + * chain of [SceneStrategy]s. If no [Scene] is calculated, the fallback will be to a + * [SinglePaneSceneStrategy]. + * + * It is allowable for different [Scene]s to render the same [NavEntry]s, perhaps on some conditions + * as determined by the [sceneStrategy] based on window size, form factor, other arbitrary logic. + * + * If this happens, and these [Scene]s are rendered at the same time due to animation or predictive + * back, then the content for the [NavEntry] will only be rendered in the most recent [Scene] that + * is the target for being the current scene as determined by [sceneStrategy]. This enforces a + * unique invocation of each [NavEntry], even if it is displayable by two different [Scene]s. + * + * @param backStack the collection of keys that represents the state that needs to be handled + * @param modifier the modifier to be applied to the layout. + * @param contentAlignment The [Alignment] of the [AnimatedContent] + * @param onBack a callback for handling system back press. The passed [Int] refers to the number of + * entries to pop from the end of the backstack, as calculated by the [sceneStrategy]. + * @param entryDecorators list of [NavEntryDecorator] to add information to the entry content + * @param sceneStrategy the [SceneStrategy] to determine which scene to render a list of entries. + * @param transitionSpec Default [ContentTransform] when navigating to [NavEntry]s. + * @param popTransitionSpec Default [ContentTransform] when popping [NavEntry]s. + * @param predictivePopTransitionSpec Default [ContentTransform] when popping with predictive back + * [NavEntry]s. + * @param entryProvider lambda used to construct each possible [NavEntry] + * @sample androidx.navigation3.ui.samples.SceneNav + * @sample androidx.navigation3.ui.samples.SceneNavSharedEntrySample + * @sample androidx.navigation3.ui.samples.SceneNavSharedElementSample + */ +@Composable +internal fun NavDisplay( + backStack: List, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + onBack: (Int) -> Unit = { + if (backStack is MutableList) { + repeat(it) { backStack.removeAt(backStack.lastIndex) } + } + }, + entryDecorators: List> = + listOf(rememberSceneSetupNavEntryDecorator(), rememberSavedStateNavEntryDecorator()), + sceneStrategy: SceneStrategy = SinglePaneSceneStrategy(), + sizeTransform: SizeTransform? = null, + transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) + }, + popTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) + }, + predictivePopTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = + NavDisplay.defaultPredictivePopTransitionSpec, + entryProvider: (key: T) -> NavEntry, +) { + require(backStack.isNotEmpty()) { "NavDisplay backstack cannot be empty" } + + var isSettled by remember { mutableStateOf(true) } + val transitionAwareLifecycleNavEntryDecorator = + transitionAwareLifecycleNavEntryDecorator(backStack, isSettled) + + DecoratedNavEntryProvider( + backStack = backStack, + entryDecorators = entryDecorators + transitionAwareLifecycleNavEntryDecorator, + entryProvider = entryProvider, + ) { entries -> + val allScenes = + mutableListOf(sceneStrategy.calculateSceneWithSinglePaneFallback(entries, onBack)) + do { + val overlayScene = allScenes.last() as? OverlayScene + val overlaidEntries = overlayScene?.overlaidEntries + if (overlaidEntries != null) { + // TODO Consider allowing a NavDisplay of only OverlayScene instances + require(overlaidEntries.isNotEmpty()) { + "Overlaid entries from $overlayScene must not be empty" + } + allScenes += + sceneStrategy.calculateSceneWithSinglePaneFallback(overlaidEntries, onBack) + } + } while (overlaidEntries != null) + val overlayScenes = allScenes.dropLast(1) + val scene = allScenes.last() + + // Predictive Back Handling + var progress by remember { mutableFloatStateOf(0f) } + var inPredictiveBack by remember { mutableStateOf(false) } + + NavigationEventHandler({ scene.previousEntries.isNotEmpty() }) { navEvent -> + progress = 0f + try { + navEvent.collect { value -> + inPredictiveBack = true + progress = value.progress + } + inPredictiveBack = false + onBack(entries.size - scene.previousEntries.size) + } finally { + inPredictiveBack = false + } + } + + // Scene Handling + val sceneKey = scene::class to scene.key + + val scenes = remember { mutableStateMapOf, Any>, Scene>() } + // TODO: This should really be a mutableOrderedStateSetOf + val mostRecentSceneKeys = remember { mutableStateListOf, Any>>() } + scenes[sceneKey] = scene + + val transitionState = remember { + // The state returned here cannot be nullable cause it produces the input of the + // transitionSpec passed into the AnimatedContent and that must match the non-nullable + // scope exposed by the transitions on the NavHost and composable APIs. + SeekableTransitionState(sceneKey) + } + + val transition = rememberTransition(transitionState, label = sceneKey.toString()) + + LaunchedEffect(transition.targetState) { + if (mostRecentSceneKeys.lastOrNull() != transition.targetState) { + mostRecentSceneKeys.remove(transition.targetState) + mostRecentSceneKeys.add(transition.targetState) + } + } + // Determine which NavEntrys should be rendered within each scene. + // Each renderable Scene, in order from the scene that is most recently the target scene to + // the scene that is least recently the target scene will be assigned each visible + // entry that hasn't already been assigned to a Scene that is more recent. + val sceneToRenderableEntryMap = + remember( + mostRecentSceneKeys.toList(), + scenes.values.map { scene -> scene.entries.map(NavEntry::key) }, + transition.targetState, + ) { + buildMap { + val coveredEntryKeys = mutableSetOf() + (mostRecentSceneKeys.filter { it != transition.targetState } + + listOf(transition.targetState)) + .fastForEachReversed { sceneKey -> + val scene = scenes.getValue(sceneKey) + put( + sceneKey, + scene.entries + .map { it.key } + .filterNot(coveredEntryKeys::contains) + .toSet(), + ) + scene.entries.forEach { coveredEntryKeys.add(it.key) } + } + } + } + + // Transition Handling + /** Keep track of the previous entries for the transition's current scene. */ + val transitionCurrentStateEntries = remember(transition.currentState) { entries.toList() } + + // Consider this a pop if the current entries match the previous entries we have recorded + // from the current state of the transition + val isPop = isPop(transitionCurrentStateEntries.map { it.key }, entries.map { it.key }) + + val zIndices = remember { mutableObjectFloatMapOf, Any>>() } + val initialKey = transition.currentState + val targetKey = transition.targetState + val initialZIndex = zIndices.getOrPut(initialKey) { 0f } + val targetZIndex = + when { + initialKey == targetKey -> initialZIndex + isPop || inPredictiveBack -> initialZIndex - 1f + else -> initialZIndex + 1f + } + zIndices[targetKey] = targetZIndex + val transitionEntry = + if (initialZIndex >= targetZIndex) { + scenes[initialKey]!!.entries.last() + } else { + scenes[targetKey]!!.entries.last() + } + + if (inPredictiveBack) { + val peekScene = + sceneStrategy.calculateSceneWithSinglePaneFallback(scene.previousEntries, onBack) + val peekSceneKey = peekScene::class to peekScene.key + scenes[peekSceneKey] = peekScene + if (transitionState.currentState != peekSceneKey) { + LaunchedEffect(progress) { transitionState.seekTo(progress, peekSceneKey) } + } + } else { + LaunchedEffect(sceneKey) { + if (transitionState.currentState != sceneKey) { + transitionState.animateTo(sceneKey) + } + // This ensures we don't animate after the back gesture is cancelled and we + // are already on the current state + if (transitionState.currentState != sceneKey) { + transitionState.animateTo(sceneKey) + } else { + // convert from nanoseconds to milliseconds + val totalDuration = transition.totalDurationNanos / 1000000 + // When the predictive back gesture is cancelled, we need to manually animate + // the SeekableTransitionState from where it left off, to zero and then + // snapTo the final position. + animate( + transitionState.fraction, + 0f, + animationSpec = tween((transitionState.fraction * totalDuration).toInt()), + ) { value, _ -> + this@LaunchedEffect.launch { + if (value > 0) { + // Seek the original transition back to the currentState + transitionState.seekTo(value) + } + if (value == 0f) { + // Once we animate to the start, we need to snap to the right state. + transitionState.snapTo(sceneKey) + } + } + } + } + } + } + + val contentTransform: AnimatedContentTransitionScope<*>.() -> ContentTransform = { + when { + inPredictiveBack -> { + transitionEntry.contentTransform(PREDICTIVE_POP_TRANSITION_SPEC)?.invoke(this) + ?: predictivePopTransitionSpec(this) + } + + isPop -> { + transitionEntry.contentTransform(POP_TRANSITION_SPEC)?.invoke(this) + ?: popTransitionSpec(this) + } + + else -> { + transitionEntry.contentTransform(TRANSITION_SPEC)?.invoke(this) + ?: transitionSpec(this) + } + } + } + + transition.AnimatedContent( + contentAlignment = contentAlignment, + modifier = modifier, + transitionSpec = { + ContentTransform( + targetContentEnter = contentTransform(this).targetContentEnter, + initialContentExit = contentTransform(this).initialContentExit, + // z-index increases during navigate and decreases during pop. + targetContentZIndex = targetZIndex, + sizeTransform = sizeTransform, + ) + }, + ) { targetSceneKey -> + val targetScene = scenes.getValue(targetSceneKey) + CompositionLocalProvider( + LocalNavAnimatedContentScope provides this, + LocalEntriesToRenderInCurrentScene provides + sceneToRenderableEntryMap.getValue(targetSceneKey), + ) { + targetScene.content() + } + } + + // Clean-up scene book-keeping once the transition is finished. + LaunchedEffect(transition) { + snapshotFlow { transition.isRunning } + .filter { !it } + .collect { + scenes.keys.toList().forEach { key -> + if (key != transition.targetState) { + scenes.remove(key) + } + } + mostRecentSceneKeys.toList().forEach { key -> + if (key != transition.targetState) { + mostRecentSceneKeys.remove(key) + } + } + } + } + + LaunchedEffect(transition.currentState, transition.targetState) { + // If we've reached the targetState, our animation has settled + val settled = transition.currentState == transition.targetState + isSettled = settled + } + + // Show all OverlayScene instances above the AnimatedContent + overlayScenes.fastForEachReversed { overlayScene -> + // TODO Calculate what entries should be displayed from sceneToRenderableEntryMap + val allEntries = overlayScene.entries.map { it.key }.toSet() + CompositionLocalProvider(LocalEntriesToRenderInCurrentScene provides allEntries) { + overlayScene.content.invoke() + } + } + } +} + +private fun isPop(oldBackStack: List, newBackStack: List): Boolean { + // entire stack replaced + if (oldBackStack.first() != newBackStack.first()) return false + // navigated + if (newBackStack.size > oldBackStack.size) return false + + val divergingIndex = + newBackStack.indices.firstOrNull { index -> newBackStack[index] != oldBackStack[index] } + // if newBackStack never diverged from oldBackStack, then it is a clean subset of the oldStack + // and is a pop + return divergingIndex == null && newBackStack.size != oldBackStack.size +} + +@Suppress("UNCHECKED_CAST") +private fun NavEntry.contentTransform( + key: String +): (AnimatedContentTransitionScope<*>.() -> ContentTransform)? { + return metadata[key] as? AnimatedContentTransitionScope<*>.() -> ContentTransform +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt new file mode 100644 index 0000000..5c39895 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEventCallback +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch + +@Composable +internal fun NavigationEventHandler( + enabled: () -> Boolean = { true }, + onBack: suspend (progress: Flow) -> Unit, +) { + // ensure we don't re-register callbacks when onBack changes + val currentOnBack by rememberUpdatedState(onBack) + val navEventScope = rememberCoroutineScope() + + val navEventCallBack = remember { + NavigationEventHandlerCallback(enabled, navEventScope, currentOnBack) + } + + // we want to use the same callback, but ensure we adjust the variable on recomposition + SideEffect { + navEventCallBack.currentOnBack = currentOnBack + navEventCallBack.onBackScope = navEventScope + } + + LaunchedEffect(enabled) { navEventCallBack.setIsEnabled(enabled()) } + + val navEventDispatcher = + checkNotNull(LocalNavigationEventDispatcherOwner.current) { + "No NavigationEventDispatcher was provided via LocalNavigationEventDispatcherOwner" + } + .navigationEventDispatcher + + DisposableEffect(navEventDispatcher) { + navEventDispatcher.addCallback(navEventCallBack) + + onDispose { navEventCallBack.remove() } + } +} + +private class OnBackInstance( + scope: CoroutineScope, + var isPredictiveBack: Boolean, + onBack: suspend (progress: Flow) -> Unit, + callback: NavigationEventCallback, +) { + val channel = + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + val job = + scope.launch { + if (callback.isEnabled) { + var completed = false + onBack(channel.consumeAsFlow().onCompletion { completed = true }) + check(completed) { "You must collect the progress flow" } + } + } + + fun send(backEvent: NavigationEvent) = channel.trySend(backEvent) + + // idempotent if invoked more than once + fun close() = channel.close() + + fun cancel() { + channel.cancel(CancellationException("navEvent cancelled")) + job.cancel() + } +} + +private class NavigationEventHandlerCallback( + enabled: () -> Boolean, + var onBackScope: CoroutineScope, + var currentOnBack: suspend (progress: Flow) -> Unit, +) : NavigationEventCallback(enabled()) { + private var onBackInstance: OnBackInstance? = null + private var isActive = false + + fun setIsEnabled(enabled: Boolean) { + // We are disabling a callback that was enabled. + if (!enabled && !isActive && isEnabled) { + onBackInstance?.cancel() + } + isEnabled = enabled + } + + override fun onEventStarted(event: NavigationEvent) { + // in case the previous onBackInstance was started by a normal back gesture + // we want to make sure it's still cancelled before we start a predictive + // back gesture + onBackInstance?.cancel() + if (isEnabled) { + onBackInstance = OnBackInstance(onBackScope, true, currentOnBack, this) + } + isActive = true + } + + override fun onEventProgressed(event: NavigationEvent) { + onBackInstance?.send(event) + } + + override fun onEventCompleted() { + // handleOnBackPressed could be called by regular back to restart + // a new back instance. If this is the case (where current back instance + // was NOT started by handleOnBackStarted) then we need to reset the previous + // regular back. + onBackInstance?.apply { + if (!isPredictiveBack) { + cancel() + onBackInstance = null + } + } + if (onBackInstance == null) { + onBackInstance = OnBackInstance(onBackScope, false, currentOnBack, this) + } + + // finally, we close the channel to ensure no more events can be sent + // but let the job complete normally + onBackInstance?.close() + onBackInstance?.isPredictiveBack = false + isActive = false + } + + override fun onEventCancelled() { + // cancel will purge the channel of any sent events that are yet to be received + onBackInstance?.cancel() + onBackInstance?.isPredictiveBack = false + isActive = false + } +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt new file mode 100644 index 0000000..19d6f59 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import com.tunjid.treenav.compose.navigation3.NavEntry + +/** + * A specific scene to render 1 or more [NavEntry] instances as an overlay. + * + * It is expected that the [content] is rendered in one or more separate windows (e.g., a dialog, + * popup window, etc.) that are visible above any additional [Scene] instances calculated from the + * [overlaidEntries]. + * + * When processing [overlaidEntries], expect processing of each [SceneStrategy] to restart from the + * first strategy. This may result in multiple instances of the same [OverlayScene] to be shown + * simultaneously, making a unique [key] even more important. + */ +internal interface OverlayScene : Scene { + + /** + * The [NavEntry]s that should be handled by another [Scene] that sits below this Scene. + * + * This *must* always be a non-empty list to correctly display entries below the overlay. + */ + public val overlaidEntries: List> +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt new file mode 100644 index 0000000..c64e1fa --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import com.tunjid.treenav.compose.navigation3.NavEntry + +/** + * A specific scene to render 1 or more [NavEntry]s. + * + * A scene instance is identified by its [key] and the class of the [Scene], and this change drives + * the top-level animation based on the [SceneStrategy] calculating what the current [Scene] is for + * the backstack. + * + * The rendering for [content] should invoke the content for each [NavEntry] contained in [entries] + * at most once concurrently in a given [Scene]. + * + * It is valid for two different instances of a [Scene] to render the same [NavEntry]. In this + * situation, the content for a [NavEntry] will only be rendered in the most recent target [Scene] + * that it is displayed in, as determined by [entries]. + */ +internal interface Scene { + /** + * The key identifying the [Scene]. This key will be combined with the class of the [Scene] to + * determine the key that drives the transition in the top-level animation for the NavDisplay. + * + * Because the class of the [Scene] is also used, this [key] only needs to be unique for a given + * type of [Scene] to indicate a different instance of the [Scene]. + */ + val key: Any + + /** + * The list of [NavEntry]s that can be displayed in this scene. + * + * When animating between scenes, the underlying content for each [NavEntry] will only be + * rendered by the scene that is most recently the target scene, and is displaying that + * [NavEntry] as determined by this [entries] list. + * + * For example, consider a transition from `Scene1` to `Scene2` below: + * ``` + * Scene1: Scene2: + * +---+---+ +---+---+ + * | | | | | | + * | A | B | --> | B | C | + * | | | | | | + * +---+---+ +---+---+ + * ``` + * + * `Scene1.entries` should be `[A, B]`, and `Scene2.entries` should be `[B, C]` + * + * When both are being rendered at the same time during the transition, the content for `A` will + * be rendered in `Scene1`, while the content for `B` and `C` will be rendered in `Scene2`. + */ + public val entries: List> + + /** + * The resulting [NavEntry]s that should be computed after pressing back updates the backstack. + * + * This is required for calculating the proper predictive back state, which may result in a + * different scene being shown. + * + * When predictive back is occurring, this list of entries will be passed through the + * [SceneStrategy] again, to determine what the resulting scene would be if the back happens. + */ + val previousEntries: List> + + /** + * The content rendering the [Scene] itself. + * + * This should call the content for the [entries], and can add any arbitrary UI around them + * specific to the [Scene]. + */ + val content: @Composable () -> Unit +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt new file mode 100644 index 0000000..b665e5b --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.key +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.tunjid.treenav.compose.navigation3.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.navEntryDecorator + +/** Returns a [SceneSetupNavEntryDecorator] that is remembered across recompositions. */ +@Composable +internal fun rememberSceneSetupNavEntryDecorator(): NavEntryDecorator = remember { + SceneSetupNavEntryDecorator() +} + +/** + * A [NavEntryDecorator] that wraps each entry in a [movableContentOf] to allow nav displays to + * arbitrarily place entries in different places in the composable call hierarchy and ensures that + * the same entry content is not composed multiple times in different places of the hierarchy. + * + * This should likely be the first [NavEntryDecorator] to ensure that other [NavEntryDecorator] + * calls that are stateful are moved properly inside the [movableContentOf]. + */ +internal fun SceneSetupNavEntryDecorator(): NavEntryDecorator { + val movableContentContentHolderMap: MutableMap Unit>> = + mutableMapOf() + val movableContentHolderMap: MutableMap Unit> = mutableMapOf() + return navEntryDecorator { entry -> + val key = entry.key + movableContentContentHolderMap.getOrPut(key) { + key(key) { + remember { + mutableStateOf( + @Composable { + error( + "Should not be called, this should always be updated in" + + "DecorateEntry with the real content" + ) + } + ) + } + } + } + movableContentHolderMap.getOrPut(key) { + key(key) { + remember { + movableContentOf { + // In case the key is removed from the backstack while this is still + // being rendered, we remember the MutableState directly to allow + // rendering it while we are animating out. + remember { movableContentContentHolderMap.getValue(key) }.value() + } + } + } + } + + if (LocalEntriesToRenderInCurrentScene.current.contains(entry.key)) { + key(key) { + // In case the key is removed from the backstack while this is still + // being rendered, we remember the MutableState directly to allow + // updating it while we are animating out. + val movableContentContentHolder = remember { + movableContentContentHolderMap.getValue(key) + } + // Update the state holder with the actual entry content + movableContentContentHolder.value = { entry.content(key) } + // In case the key is removed from the backstack while this is still + // being rendered, we remember the movableContent directly to allow + // rendering it while we are animating out. + val movableContentHolder = remember { movableContentHolderMap.getValue(key) } + // Finally, render the entry content via the movableContentOf + movableContentHolder() + } + } + } +} + +/** + * The entry keys to render in the current [Scene], in the sense of the target of the animation for + * an [AnimatedContent] that is transitioning between different scenes. + */ +public val LocalEntriesToRenderInCurrentScene: ProvidableCompositionLocal> = + compositionLocalOf { + throw IllegalStateException( + "Unexpected access to LocalEntriesToRenderInCurrentScene. You should only " + + "access LocalEntriesToRenderInCurrentScene inside a NavEntry passed " + + "to NavDisplay." + ) + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt new file mode 100644 index 0000000..f4b1d1e --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.compose.runtime.Composable +import com.tunjid.treenav.compose.navigation3.NavEntry + +/** + * A strategy that tries to calculate a [Scene] given a list of [NavEntry]. + * + * If the list of [NavEntry] does not result in a [Scene] for this strategy, `null` will be returned + * instead to delegate to another strategy. + */ +internal fun interface SceneStrategy { + /** + * Given a back stack of [entries], calculate whether this [SceneStrategy] should take on the + * task of rendering one or more of those entries. + * + * By returning a non-null [Scene], your [Scene] takes on the responsibility of rendering the + * set of entries you declare in [Scene.entries]. If you return `null`, the next available + * [SceneStrategy] will be called. + * + * @param entries The entries on the back stack that should be considered valid to render via a + * returned Scene. + * @param onBack a callback that should be connected to any internal handling of system back + * done by the returned [Scene]. The passed [Int] should be the number of entries were popped. + */ + @Composable + public fun calculateScene(entries: List>, onBack: (count: Int) -> Unit): Scene? + + /** + * Chains this [SceneStrategy] with another [sceneStrategy] to return a combined + * [SceneStrategy]. + */ + public infix fun then(sceneStrategy: SceneStrategy): SceneStrategy = + SceneStrategy { entries, onBack -> + calculateScene(entries, onBack) ?: sceneStrategy.calculateScene(entries, onBack) + } +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt new file mode 100644 index 0000000..cbac4b3 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import com.tunjid.treenav.compose.navigation3.NavEntry + +internal data class SinglePaneScene( + override val key: T, + val entry: NavEntry, + override val previousEntries: List>, +) : Scene { + override val entries: List> = listOf(entry) + + override val content: @Composable () -> Unit = { entry.content.invoke(entry.key) } +} + +/** + * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the + * list. + */ +internal class SinglePaneSceneStrategy : SceneStrategy { + @Composable + override fun calculateScene(entries: List>, onBack: (Int) -> Unit): Scene = + SinglePaneScene( + key = entries.last().key, + entry = entries.last(), + previousEntries = entries.dropLast(1), + ) +} + +@Composable +internal fun SceneStrategy.calculateSceneWithSinglePaneFallback( + entries: List>, + onBack: (count: Int) -> Unit, +): Scene = + calculateScene(entries, onBack) ?: SinglePaneSceneStrategy().calculateScene(entries, onBack) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt new file mode 100644 index 0000000..ce64520 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.tunjid.treenav.compose.navigation3.navEntryDecorator + +@Composable +internal fun transitionAwareLifecycleNavEntryDecorator(backStack: List, isSettled: Boolean) = + navEntryDecorator { entry -> + val isInBackStack = entry.key in backStack + val maxLifecycle = + when { + isInBackStack && isSettled -> Lifecycle.State.RESUMED + isInBackStack && !isSettled -> Lifecycle.State.STARTED + else /* !isInBackStack */ -> Lifecycle.State.CREATED + } + LifecycleOwner(maxLifecycle = maxLifecycle) { entry.content.invoke(entry.key) } + } + +@Composable +private fun LifecycleOwner( + maxLifecycle: Lifecycle.State = Lifecycle.State.RESUMED, + parentLifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + content: @Composable () -> Unit, +) { + val childLifecycleOwner = remember(parentLifecycleOwner) { ChildLifecycleOwner() } + // Pass LifecycleEvents from the parent down to the child + DisposableEffect(childLifecycleOwner, parentLifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + childLifecycleOwner.handleLifecycleEvent(event) + } + + parentLifecycleOwner.lifecycle.addObserver(observer) + + onDispose { parentLifecycleOwner.lifecycle.removeObserver(observer) } + } + // Ensure that the child lifecycle is capped at the maxLifecycle + LaunchedEffect(childLifecycleOwner, maxLifecycle) { + childLifecycleOwner.maxLifecycle = maxLifecycle + } + // Now install the LifecycleOwner as a composition local + CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) { content.invoke() } +} + +private class ChildLifecycleOwner : LifecycleOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + var maxLifecycle: Lifecycle.State = Lifecycle.State.INITIALIZED + set(maxState) { + field = maxState + updateState() + } + + private var parentLifecycleState: Lifecycle.State = Lifecycle.State.CREATED + + fun handleLifecycleEvent(event: Lifecycle.Event) { + parentLifecycleState = event.targetState + updateState() + } + + fun updateState() { + if (parentLifecycleState.ordinal < maxLifecycle.ordinal) { + lifecycleRegistry.currentState = parentLifecycleState + } else { + lifecycleRegistry.currentState = maxLifecycle + } + } +} \ No newline at end of file diff --git a/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt new file mode 100644 index 0000000..0b5d134 --- /dev/null +++ b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.navigationevent.NavigationEventDispatcherOwner + +@Composable +internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = + null \ No newline at end of file diff --git a/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt new file mode 100644 index 0000000..0b5d134 --- /dev/null +++ b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.navigationevent.NavigationEventDispatcherOwner + +@Composable +internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = + null \ No newline at end of file From cc823c309ca71840bfe4bfdbd979ed4ce7107fb0 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Wed, 21 May 2025 11:05:12 -0400 Subject: [PATCH 02/78] Update packages --- .../compose/DecoratedNavEntryMultiPaneDisplayScope.kt | 6 +++--- .../decorators/MovableContentNavEntryDecorator.kt | 4 ++-- .../TransitionAwareLifecycleNavEntryDecorator.kt | 2 +- .../decorators/ViewModelStoreNavEntryDecorator.kt | 4 ++-- .../{ => runtime}/DecoratedNavEntryProvider.kt | 3 +-- .../compose/navigation3/{ => runtime}/NavEntry.kt | 2 +- .../navigation3/{ => runtime}/NavEntryDecorator.kt | 2 +- .../compose/navigation3/{ => runtime}/NavEntryWrapper.kt | 2 +- .../SavedStateNavEntryDecorator.kt | 5 +---- .../tunjid/treenav/compose/navigation3/ui/DialogScene.kt | 3 +-- .../tunjid/treenav/compose/navigation3/ui/NavDisplay.kt | 9 +++++---- .../treenav/compose/navigation3/ui/OverlayScene.kt | 2 +- .../com/tunjid/treenav/compose/navigation3/ui/Scene.kt | 2 +- .../navigation3/ui/SceneSetupNavEntryDecorator.kt | 4 ++-- .../treenav/compose/navigation3/ui/SceneStrategy.kt | 2 +- .../treenav/compose/navigation3/ui/SinglePaneScene.kt | 2 +- .../ui/TransitionAwareLifecycleNavEntryDecorator.kt | 2 +- 17 files changed, 26 insertions(+), 30 deletions(-) rename library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/{ => runtime}/DecoratedNavEntryProvider.kt (98%) rename library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/{ => runtime}/NavEntry.kt (99%) rename library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/{ => runtime}/NavEntryDecorator.kt (97%) rename library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/{ => runtime}/NavEntryWrapper.kt (95%) rename library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/{decorators => runtime}/SavedStateNavEntryDecorator.kt (94%) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index d3e844c..fa91501 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -37,10 +37,10 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.navigation3.DecoratedNavEntryProvider -import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.DecoratedNavEntryProvider +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry import com.tunjid.treenav.compose.navigation3.decorators.rememberMovableContentNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.rememberSavedStateNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator import com.tunjid.treenav.compose.navigation3.decorators.transitionAwareLifecycleNavEntryDecorator diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt index 4b6cef2..e0aafaa 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt @@ -22,8 +22,8 @@ import androidx.compose.runtime.key import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator /** Returns a [MovableContentNavEntryDecorator] that is remembered across recompositions. */ @Composable diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt index 6f6c0b5..218df85 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt @@ -27,7 +27,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.compose.LocalLifecycleOwner -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator @Composable internal fun transitionAwareLifecycleNavEntryDecorator( diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt index 7153277..47e97ed 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt @@ -36,8 +36,8 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator @Composable internal expect fun shouldRemoveViewModelStoreCallback(): () -> Boolean diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/DecoratedNavEntryProvider.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt similarity index 98% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/DecoratedNavEntryProvider.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt index 497c500..5df7e1e 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/DecoratedNavEntryProvider.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3 +package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable @@ -24,7 +24,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.staticCompositionLocalOf -import com.tunjid.treenav.compose.navigation3.decorators.rememberSavedStateNavEntryDecorator import kotlin.jvm.JvmSuppressWildcards /** diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntry.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt similarity index 99% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntry.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt index 8a40bae..ba49caa 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntry.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt @@ -15,7 +15,7 @@ */ package com.tunjid.treenav.compose.navigation3 - +.runtime import androidx.compose.runtime.Composable /** diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt similarity index 97% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryDecorator.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt index 543daf8..e82ae15 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3 +package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable import kotlin.jvm.JvmSuppressWildcards diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryWrapper.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt similarity index 95% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryWrapper.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt index 2813c9e..8665c47 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryWrapper.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3 +package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SavedStateNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt similarity index 94% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SavedStateNavEntryDecorator.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt index e1bb023..2504805 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SavedStateNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3.decorators +package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -33,9 +33,6 @@ import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.savedState -import com.tunjid.treenav.compose.navigation3.NavEntry -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.navEntryDecorator /** diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt index 6d7290e..5b71f5c 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt @@ -20,8 +20,7 @@ package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.navigation3.runtime.NavEntry -import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry /** An [OverlayScene] that renders an [entry] within a [Dialog]. */ internal class DialogScene( diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt index 93de710..6eec0f4 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt @@ -44,10 +44,10 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.util.fastForEachReversed -import com.tunjid.treenav.compose.navigation3.DecoratedNavEntryProvider -import com.tunjid.treenav.compose.navigation3.NavEntry -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.rememberSavedStateNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.DecoratedNavEntryProvider +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.DEFAULT_TRANSITION_DURATION_MILLISECOND import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.POP_TRANSITION_SPEC import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.PREDICTIVE_POP_TRANSITION_SPEC @@ -265,6 +265,7 @@ internal fun NavDisplay( // Transition Handling /** Keep track of the previous entries for the transition's current scene. */ + /** Keep track of the previous entries for the transition's current scene. */ val transitionCurrentStateEntries = remember(transition.currentState) { entries.toList() } // Consider this a pop if the current entries match the previous entries we have recorded diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt index 19d6f59..8dda4a4 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt @@ -16,7 +16,7 @@ package com.tunjid.treenav.compose.navigation3.ui -import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry /** * A specific scene to render 1 or more [NavEntry] instances as an overlay. diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt index c64e1fa..2c0173d 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt @@ -17,7 +17,7 @@ package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable -import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry /** * A specific scene to render 1 or more [NavEntry]s. diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt index b665e5b..6e238fc 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt @@ -25,8 +25,8 @@ import androidx.compose.runtime.key import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator /** Returns a [SceneSetupNavEntryDecorator] that is remembered across recompositions. */ @Composable diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt index f4b1d1e..d9f1f83 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt @@ -18,7 +18,7 @@ package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable -import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry /** * A strategy that tries to calculate a [Scene] given a list of [NavEntry]. diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt index cbac4b3..f890b32 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt @@ -17,7 +17,7 @@ package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable -import com.tunjid.treenav.compose.navigation3.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry internal data class SinglePaneScene( override val key: T, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt index ce64520..938050b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt @@ -27,7 +27,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.compose.LocalLifecycleOwner -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator @Composable internal fun transitionAwareLifecycleNavEntryDecorator(backStack: List, isSettled: Boolean) = From 36b6a99548aa13724e060ddb3c5e5bc69bf33e3c Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 24 May 2025 10:32:00 -0400 Subject: [PATCH 03/78] Add MultiPaneDisplayScene, WIP --- gradle/libs.versions.toml | 3 +- .../treenav/compose/MultiPaneDisplayScene.kt | 286 ++++++++++++++++++ .../java/com/tunjid/tyler/MainActivity.kt | 37 +-- .../com/tunjid/demo/common/ui/DemoApp.kt | 33 +- 4 files changed, 310 insertions(+), 49 deletions(-) create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5d7865..c6b188d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] androidGradlePlugin = "8.9.2" androidxActivity = "1.9.2" -activity-compose = "1.11.0-rc01" +activity-compose = "1.12.0-alpha01" androidxAppCompat = "1.7.0" androidxBenchmark = "1.3.4" androidxCore = "1.16.0" @@ -38,6 +38,7 @@ android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", ver compose-compiler-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity-compose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } androidx-navigation-event = { group = "androidx.navigationevent", name = "navigationevent", version.ref = "androidxNavigationEvent" } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt new file mode 100644 index 0000000..71ebb3c --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -0,0 +1,286 @@ +/* + * 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.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.ui.LocalNavAnimatedContentScope +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay +import com.tunjid.treenav.compose.navigation3.ui.Scene +import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy +import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun MultiPaneDisplay2( + sharedTransitionScope: SharedTransitionScope, + state: MultiPaneDisplayState, + pop: NavigationState.() -> NavigationState, + goBack: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable MultiPaneDisplayScope.() -> Unit, +) { + val navigationState by state.navigationState + val panesToDestinations = rememberUpdatedState( + state.panesToDestinationsTransform( + state.destinationTransform(navigationState) + ) + ) + + val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> + state.backStackTransform(navigationState).let { currentBackStack -> + mutableBackStack.clear() + mutableBackStack.addAll(currentBackStack) + } + } + + val slots = remember { + List( + size = state.panes.size, + init = ::Slot + ).toSet() + } + + val panedNavigationState = remember { + mutableStateOf( + value = SlotBasedPanedNavigationState.initial(slots = slots) + .adaptTo( + slots = slots, + panesToDestinations = panesToDestinations.value, + backStackIds = backStack.map(Node::id), + ) + ) + } + .also { + it.updateOnChange( + backStackIds = backStack.map(Node::id), + panesToDestinations = panesToDestinations.value, + slots = slots + ) + } + + val sceneStrategy = remember { + MultiPanePaneSceneStrategy( + state = state, + slots = slots, + currentPanedNavigationState = panedNavigationState::value, + pop = pop, + content = content, + ) + } + + NavDisplay( + backStack = backStack, + modifier = modifier, + onBack = { + goBack() + }, + entryDecorators = listOf( + navEntryDecorator { entry -> + with(sharedTransitionScope) { + Box( + Modifier.sharedElement( + rememberSharedContentState(entry.key), + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + ), + ) { + entry.content(entry.key) + } + } + }, + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + sceneStrategy = sceneStrategy, + entryProvider = { key -> + NavEntry( + key = key, + content = { destination -> + val scope = LocalPaneScope.current + @Suppress("UNCHECKED_CAST") + state.renderTransform(scope as PaneScope, destination) + }, + ) + }, + ) +} + +private fun MutableState>.updateOnChange( + backStackIds: List, + panesToDestinations: Map, + slots: Set +) { + val backStackChanged = value.backStackIds != backStackIds + val paneMappingChanged = value.panesToDestinations != panesToDestinations + + if (backStackChanged || paneMappingChanged) { + value = value.adaptTo( + slots = slots, + panesToDestinations = panesToDestinations, + backStackIds = backStackIds, + ) + } +} + + +@Stable +private class MultiPanePaneSceneStrategy( + private val state: MultiPaneDisplayState, + private val slots: Set, + private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, + private val pop: NavigationState.() -> NavigationState, + private val content: @Composable (MultiPaneDisplayScope.() -> Unit), +) : SceneStrategy { + + @Composable + override fun calculateScene( + entries: List>, + onBack: (count: Int) -> Unit + ): Scene { + + val backstackIds = entries.map { it.key.id } + + // Calculate the scene for the entries specified. + // Since there might be a predictive back gesture, pop until the right navigation state + // is found + val current = remember(backstackIds) { + var navigationState = state.navigationState.value + while (state.backStackTransform(navigationState).map { it.id } != backstackIds) { + navigationState = navigationState.pop() + } + navigationState + } + + val activeIds = remember(backstackIds) { + state.destinationTransform(current) + .let { destination -> + destination.children.mapTo(mutableSetOf(), Node::id) + destination.id + } + } + + val poppedBackstackIds = remember(backstackIds) { + state.backStackTransform(current.pop()) + .mapTo( + destination = mutableSetOf(), + transform = Node::id + ) + } + + return remember(backstackIds) { + MultiPaneDisplayScene( + destination = state.destinationTransform(current), + slots = slots, + panesToDestinations = state.panesToDestinationsTransform, + currentPanedNavigationState = currentPanedNavigationState, + entries = entries.filter { it.key.id in activeIds }, + previousEntries = entries.filter { it.key.id in poppedBackstackIds }, + scopeContent = content + ) + } + } +} + +private class MultiPaneDisplayScene( + private val destination: Destination, + private val slots: Set, + private val panesToDestinations: @Composable (Destination) -> Map, + private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, + override val entries: List>, + override val previousEntries: List>, + private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), +) : Scene { + + override val key: Any = destination.id + + override val content: @Composable () -> Unit = { + + val panedNavigationState by remember { + mutableStateOf(currentPanedNavigationState()) + }.also { + it.updateOnChange( + backStackIds = entries.map { destinationNavEntry -> destinationNavEntry.key.id }, + panesToDestinations = panesToDestinations(destination), + slots = slots, + ) + } + + val multiPaneDisplayScope: MultiPaneDisplayScope = remember { + object : MultiPaneDisplayScope { + + @Composable + override fun Destination(pane: Pane) { + val id = panedNavigationState.destinationFor(pane)?.id + val entry = entries.firstOrNull { it.key.id == id } ?: return + + val paneState = panedNavigationState.slotFor(pane) + ?.let(panedNavigationState::paneStateFor) ?: return + + val animatedContentScope = LocalNavAnimatedContentScope.current + + val scope = remember { + AnimatedPaneScope( + paneState = paneState, + activeState = derivedStateOf { + animatedContentScope.transition.targetState == EnterExitState.Visible + }, + animatedContentScope = animatedContentScope, + ) + }.also { it.paneState = paneState } + + CompositionLocalProvider( + LocalPaneScope provides scope + ) { + entry.content(entry.key) + } + } + + override fun adaptationsIn(pane: Pane): Set = + panedNavigationState.adaptationsIn(pane) + + override fun destinationIn(pane: Pane): Destination? = + panedNavigationState.destinationFor(pane) + } + } + multiPaneDisplayScope.scopeContent() + } +} + +private val LocalPaneScope = staticCompositionLocalOf> { + throw IllegalArgumentException( + "PaneScope should not be read until provided in the composition" + ) +} diff --git a/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt index 3b7a87e..fd8ba47 100644 --- a/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt +++ b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt @@ -17,56 +17,25 @@ package com.tunjid.tyler import android.os.Bundle -import androidx.activity.BackEventCompat -import androidx.activity.compose.PredictiveBackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.remember -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.round +import androidx.compose.ui.platform.LocalView +import androidx.navigationevent.setViewTreeNavigationEventDispatcherOwner import com.tunjid.demo.common.ui.App import com.tunjid.demo.common.ui.AppState import com.tunjid.demo.common.ui.AppTheme -import kotlinx.coroutines.flow.Flow -import kotlin.coroutines.cancellation.CancellationException class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { + LocalView.current.setViewTreeNavigationEventDispatcherOwner(this) AppTheme { val appState = remember { AppState() } App(appState) - - PredictiveBackHandler { backEvents: Flow -> - try { - backEvents.collect { backEvent -> - appState.backPreviewState.apply { - atStart = backEvent.swipeEdge == BackEventCompat.EDGE_LEFT - progress = backEvent.progress - pointerOffset = Offset( - x = backEvent.touchX, - y = backEvent.touchY - ).round() - } - } - // Dismiss back preview - appState.backPreviewState.apply { - progress = Float.NaN - pointerOffset = IntOffset.Zero - } - // Pop navigation - appState.goBack() - } catch (e: CancellationException) { - appState.backPreviewState.apply { - progress = Float.NaN - pointerOffset = IntOffset.Zero - } - } - } } } } 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 c2680c4..c27ac2d 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 @@ -76,6 +76,7 @@ import com.tunjid.demo.common.ui.me.mePaneEntry import com.tunjid.demo.common.ui.profile.profilePaneEntry import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.compose.MultiPaneDisplay +import com.tunjid.treenav.compose.MultiPaneDisplay2 import com.tunjid.treenav.compose.MultiPaneDisplayScope import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState @@ -84,6 +85,7 @@ import com.tunjid.treenav.compose.threepane.ThreePane 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.RenderTransform import com.tunjid.treenav.compose.transforms.Transform import com.tunjid.treenav.compose.transforms.paneModifierTransform import com.tunjid.treenav.pop @@ -109,9 +111,12 @@ fun App( sharedTransitionScope = this ) } - MultiPaneDisplay( + MultiPaneDisplay2( + sharedTransitionScope = this, modifier = Modifier .fillMaxSize(), + pop = MultiStackNav::pop, + goBack = appState::goBack, state = appState.rememberMultiPaneDisplayState( remember { listOf( @@ -126,22 +131,22 @@ fun App( appState.splitLayoutState.size } ), - backPreviewTransform( - isPreviewingBack = derivedStateOf { - appState.isPreviewingBack - }, - navigationStateBackTransform = MultiStackNav::pop, - ), +// backPreviewTransform( +// isPreviewingBack = derivedStateOf { +// appState.isPreviewingBack +// }, +// navigationStateBackTransform = MultiStackNav::pop, +// ), threePanedMovableSharedElementTransform( movableSharedElementHostState = movableSharedElementHostState ), - paneModifierTransform { - if (paneState.pane == ThreePane.TransientPrimary) Modifier - .fillMaxSize() - .backPreview(appState.backPreviewState) - else Modifier - .fillMaxSize() - } +// paneModifierTransform { +// if (paneState.pane == ThreePane.TransientPrimary) Modifier +// .fillMaxSize() +// .backPreview(appState.backPreviewState) +// else Modifier +// .fillMaxSize() +// }, ) } ), From f8433b30608d91a904cb99d59442137b4b878bd1 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 24 May 2025 15:38:47 -0400 Subject: [PATCH 04/78] Only update back stack if it changes --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt index 71ebb3c..89d8820 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -62,6 +62,9 @@ fun MultiPaneDisplay2( val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> state.backStackTransform(navigationState).let { currentBackStack -> + val sameBackStack = currentBackStack == mutableBackStack + if (sameBackStack) return@let + mutableBackStack.clear() mutableBackStack.addAll(currentBackStack) } From 588e9fa3cdc5e09cd6b057dabbd6ac28726ef1d2 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 17:09:40 -0400 Subject: [PATCH 05/78] Match up navigation states --- .../treenav/compose/MultiPaneDisplayScene.kt | 83 +++++++++++-------- .../treenav/compose/MultiPaneDisplayState.kt | 13 +++ .../com/tunjid/demo/common/ui/DemoApp.kt | 16 ++-- .../tunjid/demo/common/ui/chat/ChatScreen.kt | 13 +-- .../demo/common/ui/chat/ChatViewModel.kt | 8 -- .../tunjid/demo/common/ui/chat/PaneEntry.kt | 10 +-- 6 files changed, 80 insertions(+), 63 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt index 89d8820..31fdd29 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -48,10 +48,8 @@ import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecor fun MultiPaneDisplay2( sharedTransitionScope: SharedTransitionScope, state: MultiPaneDisplayState, - pop: NavigationState.() -> NavigationState, - goBack: () -> Unit, modifier: Modifier = Modifier, - content: @Composable MultiPaneDisplayScope.() -> Unit, + content: @Composable (MultiPaneDisplayScope.() -> Unit), ) { val navigationState by state.navigationState val panesToDestinations = rememberUpdatedState( @@ -100,7 +98,6 @@ fun MultiPaneDisplay2( state = state, slots = slots, currentPanedNavigationState = panedNavigationState::value, - pop = pop, content = content, ) } @@ -108,8 +105,18 @@ fun MultiPaneDisplay2( NavDisplay( backStack = backStack, modifier = modifier, - onBack = { - goBack() + onBack = { count -> + val poppedBackStackIds = state.backStackTransform(navigationState) + .map { it.id } + .dropLast(count) + val poppedNavigationState = navigationState.findStateMatching( + backstackIds = poppedBackStackIds, + backStackTransform = { + state.backStackTransform(it).map(Node::id) + }, + pop = state.popTransform, + ) + state.onPopped(poppedNavigationState) }, entryDecorators = listOf( navEntryDecorator { entry -> @@ -165,7 +172,6 @@ private class MultiPanePaneSceneStrategy, private val slots: Set, private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, - private val pop: NavigationState.() -> NavigationState, private val content: @Composable (MultiPaneDisplayScope.() -> Unit), ) : SceneStrategy { @@ -177,38 +183,36 @@ private class MultiPanePaneSceneStrategy + state.backStackTransform(navigationState).map { it.id } + }, + pop = state.popTransform, + ) - val activeIds = remember(backstackIds) { - state.destinationTransform(current) + val activeIds = state.destinationTransform(current) .let { destination -> destination.children.mapTo(mutableSetOf(), Node::id) + destination.id } - } - val poppedBackstackIds = remember(backstackIds) { - state.backStackTransform(current.pop()) - .mapTo( - destination = mutableSetOf(), - transform = Node::id - ) - } + val poppedBackstackIds = state.backStackTransform(state.popTransform(current)) + .mapTo( + destination = mutableSetOf(), + transform = Node::id + ) - return remember(backstackIds) { MultiPaneDisplayScene( + backstackIds = backstackIds, destination = state.destinationTransform(current), slots = slots, panesToDestinations = state.panesToDestinationsTransform, - currentPanedNavigationState = currentPanedNavigationState, + currentPanedNavigationState = currentPanedNavigationState(), entries = entries.filter { it.key.id in activeIds }, previousEntries = entries.filter { it.key.id in poppedBackstackIds }, scopeContent = content @@ -218,13 +222,14 @@ private class MultiPanePaneSceneStrategy( + private val backstackIds: List, private val destination: Destination, private val slots: Set, private val panesToDestinations: @Composable (Destination) -> Map, - private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, + private val currentPanedNavigationState: SlotBasedPanedNavigationState, override val entries: List>, override val previousEntries: List>, - private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), + private val scopeContent: @Composable() (MultiPaneDisplayScope.() -> Unit), ) : Scene { override val key: Any = destination.id @@ -232,10 +237,10 @@ private class MultiPaneDisplayScene( override val content: @Composable () -> Unit = { val panedNavigationState by remember { - mutableStateOf(currentPanedNavigationState()) + mutableStateOf(currentPanedNavigationState) }.also { it.updateOnChange( - backStackIds = entries.map { destinationNavEntry -> destinationNavEntry.key.id }, + backStackIds = backstackIds, panesToDestinations = panesToDestinations(destination), slots = slots, ) @@ -282,6 +287,18 @@ private class MultiPaneDisplayScene( } } +private fun NavigationState.findStateMatching( + backstackIds: List, + backStackTransform: (NavigationState) -> List, + pop: (NavigationState) -> NavigationState, +): NavigationState { + var navigationState = this + while (backStackTransform(navigationState) != backstackIds) { + navigationState = pop(navigationState) + } + return navigationState +} + private val LocalPaneScope = staticCompositionLocalOf> { throw IllegalArgumentException( "PaneScope should not be read until provided in the composition" diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 0e39bee..209a5a4 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -19,6 +19,7 @@ package com.tunjid.treenav.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.State import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator import com.tunjid.treenav.compose.transforms.CompoundTransform import com.tunjid.treenav.compose.transforms.DestinationTransform import com.tunjid.treenav.compose.transforms.PaneTransform @@ -35,6 +36,8 @@ import com.tunjid.treenav.compose.transforms.Transform * @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 popTransform a transform of the [navigationState] when back is pressed. + * @param onPopped an action to perform when the navigation state has been popped to a new state. * @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. @@ -44,6 +47,8 @@ class MultiPaneDisplayState in val navigationState: State, val backStackTransform: (NavigationState) -> List, val destinationTransform: (NavigationState) -> Destination, + val popTransform: (NavigationState) -> NavigationState, + val onPopped: (NavigationState) -> Unit, val panesToDestinationsTransform: @Composable (Destination) -> Map, val renderTransform: @Composable PaneScope.(Destination) -> Unit, ) @@ -59,6 +64,8 @@ class MultiPaneDisplayState in * @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 popTransform a transform of the [navigationState] when back is pressed. + * @param onPopped an action to perform when the navigation state has been popped to a new state. * @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 @@ -69,6 +76,8 @@ fun MultiPaneDisplayState( navigationState: State, backStackTransform: (NavigationState) -> List, destinationTransform: (NavigationState) -> Destination, + popTransform: (NavigationState) -> NavigationState, + onPopped: (NavigationState) -> Unit, entryProvider: (Destination) -> PaneEntry, transforms: List>, ) = transforms.fold( @@ -77,6 +86,8 @@ fun MultiPaneDisplayState( navigationState = navigationState, backStackTransform = backStackTransform, destinationTransform = destinationTransform, + popTransform = popTransform, + onPopped = onPopped, panesToDestinationsTransform = { destination -> entryProvider(destination).paneTransform(destination) }, @@ -105,6 +116,8 @@ private operator fun panes = panes, navigationState = navigationState, backStackTransform = backStackTransform, + popTransform = popTransform, + onPopped = onPopped, destinationTransform = when (transform) { is DestinationTransform -> { destination -> transform.toDestination( 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 c27ac2d..8945f3b 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 @@ -63,7 +63,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp 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.rememberMultiPaneDisplayState @@ -75,19 +74,15 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.me.mePaneEntry import com.tunjid.demo.common.ui.profile.profilePaneEntry import com.tunjid.treenav.MultiStackNav -import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.MultiPaneDisplay2 import com.tunjid.treenav.compose.MultiPaneDisplayScope import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.multiPaneDisplayBackstack import com.tunjid.treenav.compose.threepane.ThreePane -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.RenderTransform import com.tunjid.treenav.compose.transforms.Transform -import com.tunjid.treenav.compose.transforms.paneModifierTransform import com.tunjid.treenav.pop import com.tunjid.treenav.popToRoot import com.tunjid.treenav.requireCurrent @@ -111,12 +106,11 @@ fun App( sharedTransitionScope = this ) } + MultiPaneDisplay2( sharedTransitionScope = this, modifier = Modifier .fillMaxSize(), - pop = MultiStackNav::pop, - goBack = appState::goBack, state = appState.rememberMultiPaneDisplayState( remember { listOf( @@ -148,7 +142,7 @@ fun App( // .fillMaxSize() // }, ) - } + }, ), ) { appState.displayScope = this @@ -326,6 +320,12 @@ class AppState( navigationState = navigationState, backStackTransform = MultiStackNav::multiPaneDisplayBackstack, destinationTransform = MultiStackNav::requireCurrent, + popTransform = MultiStackNav::pop, + onPopped = { poppedNavigationState -> + navigationRepository.navigate { + poppedNavigationState + } + }, entryProvider = { destination -> when (destination) { SampleDestination.NavTabs.ChatRooms -> chatRoomPaneEntry() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt index 32dfd90..aae0221 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.SampleTopAppBar @@ -54,13 +55,14 @@ import com.tunjid.demo.common.ui.data.Message import com.tunjid.demo.common.ui.data.Profile import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf +import com.tunjid.treenav.compose.threepane.ThreePane import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @Composable fun ChatScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -69,10 +71,11 @@ fun ChatScreen( Column( modifier, ) { + val isInPrimaryPane = paneScaffoldState.paneState.pane == ThreePane.Primary SampleTopAppBar( title = state.room?.name ?: "", - onBackPressed = remember(state.isInPrimaryPane) { - if (state.isInPrimaryPane) return@remember { + onBackPressed = remember(isInPrimaryPane) { + if (isInPrimaryPane) return@remember { onAction(Action.Navigation.Pop) } else null }, @@ -81,11 +84,11 @@ fun ChatScreen( me = state.me, roomName = state.room?.name, messages = state.chats, - isInPrimaryPane = state.isInPrimaryPane, + isInPrimaryPane = isInPrimaryPane, navigateToProfile = onAction, modifier = Modifier.fillMaxSize(), scrollState = scrollState, - movableSharedElementScope = movableSharedElementScope, + movableSharedElementScope = paneScaffoldState, ) } } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt index 384b5a3..1c4184d 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt @@ -65,7 +65,6 @@ class ChatViewModel( keySelector = Action::key ) { when (val type = type()) { - is Action.UpdateInPrimaryPane -> type.flow.updateInPrimaryPaneMutations() is Action.Navigation -> navigationRepository.navigationMutations( type.flow ) @@ -105,13 +104,9 @@ private fun chatLoadMutations( copy(chats = it) } -private fun Flow.updateInPrimaryPaneMutations(): Flow> = - mapToMutation { copy(isInPrimaryPane = it.isInPrimaryPane) } - data class State( val me: Profile? = null, val room: ChatRoom? = null, - val isInPrimaryPane: Boolean = true, val chats: List = emptyList(), ) @@ -124,9 +119,6 @@ sealed class Action( val key: String, ) { - data class UpdateInPrimaryPane( - val isInPrimaryPane: Boolean, - ) : Action("UpdateInPrimaryPane") sealed class Navigation : Action("Navigation"), NavigationAction { data object Pop : Navigation(), NavigationAction by navigationAction( diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt index 975362d..ade5801 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt @@ -17,7 +17,6 @@ package com.tunjid.demo.common.ui.chat import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -55,17 +54,10 @@ fun chatPaneEntry() = threePaneEntry( .fillMaxSize(), content = { ChatScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, ) - LaunchedEffect(paneState.pane) { - viewModel.accept( - Action.UpdateInPrimaryPane( - isInPrimaryPane = paneState.pane == ThreePane.Primary - ) - ) - } }, navigationBar = { PaneNavigationBar() From 3e9eb7bc63933fedd4a90bfbc0627562b40ca9dc Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 17:19:59 -0400 Subject: [PATCH 06/78] Clean up code --- .../treenav/compose/MultiPaneDisplayScene.kt | 76 +++++++++---------- .../treenav/compose/MultiPaneDisplayState.kt | 1 - 2 files changed, 35 insertions(+), 42 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt index 31fdd29..2f987fa 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -22,8 +22,8 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -76,22 +76,17 @@ fun MultiPaneDisplay2( } val panedNavigationState = remember { - mutableStateOf( - value = SlotBasedPanedNavigationState.initial(slots = slots) - .adaptTo( - slots = slots, - panesToDestinations = panesToDestinations.value, - backStackIds = backStack.map(Node::id), - ) - ) - } - .also { - it.updateOnChange( - backStackIds = backStack.map(Node::id), + SlotBasedPanedNavigationState.initial(slots = slots) + .adaptTo( + slots = slots, panesToDestinations = panesToDestinations.value, - slots = slots + backStackIds = backStack.map(Node::id), ) - } + }.rememberUpdatedPanedNavigationState( + backStackIds = backStack.map(Node::id), + panesToDestinations = panesToDestinations.value, + slots = slots + ) val sceneStrategy = remember { MultiPanePaneSceneStrategy( @@ -149,23 +144,26 @@ fun MultiPaneDisplay2( ) } -private fun MutableState>.updateOnChange( +@Composable +private fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( backStackIds: List, panesToDestinations: Map, slots: Set -) { - val backStackChanged = value.backStackIds != backStackIds - val paneMappingChanged = value.panesToDestinations != panesToDestinations - - if (backStackChanged || paneMappingChanged) { - value = value.adaptTo( - slots = slots, - panesToDestinations = panesToDestinations, - backStackIds = backStackIds, - ) +): State> = + remember { + mutableStateOf(this) + }.also { + val backStackChanged = it.value.backStackIds != backStackIds + val paneMappingChanged = it.value.panesToDestinations != panesToDestinations + + if (backStackChanged || paneMappingChanged) { + it.value = it.value.adaptTo( + slots = slots, + panesToDestinations = panesToDestinations, + backStackIds = backStackIds, + ) + } } -} - @Stable private class MultiPanePaneSceneStrategy( @@ -202,10 +200,10 @@ private class MultiPanePaneSceneStrategy( override val content: @Composable () -> Unit = { - val panedNavigationState by remember { - mutableStateOf(currentPanedNavigationState) - }.also { - it.updateOnChange( - backStackIds = backstackIds, - panesToDestinations = panesToDestinations(destination), - slots = slots, - ) - } + val panedNavigationState by currentPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds = backstackIds, + panesToDestinations = panesToDestinations(destination), + slots = slots, + ) val multiPaneDisplayScope: MultiPaneDisplayScope = remember { object : MultiPaneDisplayScope { diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 209a5a4..d0146ae 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -19,7 +19,6 @@ package com.tunjid.treenav.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.State import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator import com.tunjid.treenav.compose.transforms.CompoundTransform import com.tunjid.treenav.compose.transforms.DestinationTransform import com.tunjid.treenav.compose.transforms.PaneTransform From 20b376bc27a42cd72e89a728c46b4a4ee6f0ae49 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 17:21:12 -0400 Subject: [PATCH 07/78] Move utility methods --- .../treenav/compose/MultiPaneDisplayScene.kt | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt index 2f987fa..d3f3264 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -144,27 +144,6 @@ fun MultiPaneDisplay2( ) } -@Composable -private fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( - backStackIds: List, - panesToDestinations: Map, - slots: Set -): State> = - remember { - mutableStateOf(this) - }.also { - val backStackChanged = it.value.backStackIds != backStackIds - val paneMappingChanged = it.value.panesToDestinations != panesToDestinations - - if (backStackChanged || paneMappingChanged) { - it.value = it.value.adaptTo( - slots = slots, - panesToDestinations = panesToDestinations, - backStackIds = backStackIds, - ) - } - } - @Stable private class MultiPanePaneSceneStrategy( private val state: MultiPaneDisplayState, @@ -293,6 +272,27 @@ private fun NavigationState.findStateMatching( return navigationState } +@Composable +private fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds: List, + panesToDestinations: Map, + slots: Set +): State> = + remember { + mutableStateOf(this) + }.also { + val backStackChanged = it.value.backStackIds != backStackIds + val paneMappingChanged = it.value.panesToDestinations != panesToDestinations + + if (backStackChanged || paneMappingChanged) { + it.value = it.value.adaptTo( + slots = slots, + panesToDestinations = panesToDestinations, + backStackIds = backStackIds, + ) + } + } + private val LocalPaneScope = staticCompositionLocalOf> { throw IllegalArgumentException( "PaneScope should not be read until provided in the composition" From c5081ecbe66bc7959cb9732b6d2670e815dbf079 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 17:57:36 -0400 Subject: [PATCH 08/78] Update MultiPaneDisplayScene --- .../com/tunjid/treenav/compose/MultiPaneDisplayScene.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt index d3f3264..e4ea3ff 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -75,14 +75,16 @@ fun MultiPaneDisplay2( ).toSet() } - val panedNavigationState = remember { + val initialPanedNavigationState = remember { SlotBasedPanedNavigationState.initial(slots = slots) .adaptTo( slots = slots, panesToDestinations = panesToDestinations.value, backStackIds = backStack.map(Node::id), ) - }.rememberUpdatedPanedNavigationState( + } + + val panedNavigationState = initialPanedNavigationState.rememberUpdatedPanedNavigationState( backStackIds = backStack.map(Node::id), panesToDestinations = panesToDestinations.value, slots = slots @@ -206,7 +208,7 @@ private class MultiPaneDisplayScene( private val currentPanedNavigationState: SlotBasedPanedNavigationState, override val entries: List>, override val previousEntries: List>, - private val scopeContent: @Composable() (MultiPaneDisplayScope.() -> Unit), + private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), ) : Scene { override val key: Any = destination.id From 3986befae073be9fd0a463a830a6a7d42090b4af Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 18:03:18 -0400 Subject: [PATCH 09/78] Simplify code --- .../com/tunjid/treenav/compose/MultiPaneDisplayScene.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt index e4ea3ff..977d598 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -106,10 +106,10 @@ fun MultiPaneDisplay2( val poppedBackStackIds = state.backStackTransform(navigationState) .map { it.id } .dropLast(count) - val poppedNavigationState = navigationState.findStateMatching( + val poppedNavigationState = state.navigationState.value.findStateMatching( backstackIds = poppedBackStackIds, - backStackTransform = { - state.backStackTransform(it).map(Node::id) + backStackTransform = { navigationState -> + state.backStackTransform(navigationState).map(Node::id) }, pop = state.popTransform, ) @@ -170,7 +170,7 @@ private class MultiPanePaneSceneStrategy - state.backStackTransform(navigationState).map { it.id } + state.backStackTransform(navigationState).map(Node::id) }, pop = state.popTransform, ) From 2347781741ec57301ebe781dbe74507652b85ccf Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 18:14:53 -0400 Subject: [PATCH 10/78] Tidy up code --- .../treenav/compose/MultiPaneDisplayScene.kt | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt index 977d598..127da49 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt @@ -104,15 +104,13 @@ fun MultiPaneDisplay2( modifier = modifier, onBack = { count -> val poppedBackStackIds = state.backStackTransform(navigationState) - .map { it.id } + .map(Node::id) .dropLast(count) - val poppedNavigationState = state.navigationState.value.findStateMatching( + + val poppedNavigationState = state.findNavigationStateMatching( backstackIds = poppedBackStackIds, - backStackTransform = { navigationState -> - state.backStackTransform(navigationState).map(Node::id) - }, - pop = state.popTransform, ) + state.onPopped(poppedNavigationState) }, entryDecorators = listOf( @@ -167,12 +165,8 @@ private class MultiPanePaneSceneStrategy - state.backStackTransform(navigationState).map(Node::id) - }, - pop = state.popTransform, ) val activeIds = state.destinationTransform(current) @@ -201,13 +195,13 @@ private class MultiPanePaneSceneStrategy( + override val entries: List>, + override val previousEntries: List>, private val backstackIds: List, private val destination: Destination, private val slots: Set, private val panesToDestinations: @Composable (Destination) -> Map, private val currentPanedNavigationState: SlotBasedPanedNavigationState, - override val entries: List>, - override val previousEntries: List>, private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), ) : Scene { @@ -262,16 +256,14 @@ private class MultiPaneDisplayScene( } } -private fun NavigationState.findStateMatching( +private fun MultiPaneDisplayState<*, NavigationState, *>.findNavigationStateMatching( backstackIds: List, - backStackTransform: (NavigationState) -> List, - pop: (NavigationState) -> NavigationState, ): NavigationState { - var navigationState = this - while (backStackTransform(navigationState) != backstackIds) { - navigationState = pop(navigationState) + var state = navigationState.value + while (backStackTransform(state).map(Node::id) != backstackIds) { + state = popTransform(state) } - return navigationState + return state } @Composable From f3c4925ed1fd918d930fc2c3f4b3930703f655c0 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 18:52:53 -0400 Subject: [PATCH 11/78] Remove unnecessary animations on PaneEntry --- .../treenav/compose/threepane/ThreePane.kt | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 877bd12..f4db06d 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -22,9 +22,7 @@ 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 @@ -103,39 +101,11 @@ fun threePaneEntry( paneMapping: @Composable (R) -> Map = { mapOf(ThreePane.Primary to it) }, - render: @Composable PaneScope.(R) -> Unit, + render: @Composable (PaneScope.(R) -> Unit), ) = PaneEntry( paneTransform = paneMapping, renderTransform = { destination, original -> - val state = paneState - val shouldAnimate = when (state.pane) { - ThreePane.Primary, - ThreePane.Secondary, - -> when { - ThreePane.PrimaryToSecondary in state.adaptations -> false - ThreePane.SecondaryToPrimary in state.adaptations -> false - else -> true - } - - ThreePane.TransientPrimary -> when { - ThreePane.PrimaryToTransient in state.adaptations -> false - else -> true - } - - else -> true - } - Box( - modifier = - if (shouldAnimate) Modifier.animateEnterExit( - enter = enterTransition(), - exit = exitTransition() - ) - else Modifier, - content = { - original(destination) - } - ) - + original(destination) }, content = render ) From 379692e67d6c1814e03c3792c8e742a0b5385b3a Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 25 May 2025 21:34:33 -0400 Subject: [PATCH 12/78] Continue consolidating on MultipaneDisplay --- .../transforms/BackPreviewTransform.kt | 66 ---- .../treenav/compose/MultiPaneDisplay.kt | 250 ++++++++++++++- .../treenav/compose/MultiPaneDisplayScene.kt | 294 ------------------ .../com/tunjid/demo/common/ui/DemoApp.kt | 9 +- 4 files changed, 248 insertions(+), 371 deletions(-) delete mode 100644 library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt deleted file mode 100644 index 3651a30..0000000 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt +++ /dev/null @@ -1,66 +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.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/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 0a48690..52fb227 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -17,10 +17,19 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.Box +import androidx.compose.animation.EnterExitState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -28,6 +37,14 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.ui.LocalNavAnimatedContentScope +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay +import com.tunjid.treenav.compose.navigation3.ui.Scene +import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy +import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator /** * Scope that provides context about individual panes [Pane] in an [MultiPaneDisplay]. @@ -76,12 +93,233 @@ fun MultiPaneDisplay( modifier: Modifier = Modifier, content: @Composable MultiPaneDisplayScope.() -> Unit, ) { - Box( - modifier = modifier - ) { - DecoratedNavEntryMultiPaneDisplayScope( + val navigationState by state.navigationState + val panesToDestinations = rememberUpdatedState( + state.panesToDestinationsTransform( + state.destinationTransform(navigationState) + ) + ) + + val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> + state.backStackTransform(navigationState).let { currentBackStack -> + val sameBackStack = currentBackStack == mutableBackStack + if (sameBackStack) return@let + + mutableBackStack.clear() + mutableBackStack.addAll(currentBackStack) + } + } + + val slots = remember { + List( + size = state.panes.size, + init = ::Slot + ).toSet() + } + + val initialPanedNavigationState = remember { + SlotBasedPanedNavigationState.initial(slots = slots) + .adaptTo( + slots = slots, + panesToDestinations = panesToDestinations.value, + backStackIds = backStack.map(Node::id), + ) + } + + val panedNavigationState = initialPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds = backStack.map(Node::id), + panesToDestinations = panesToDestinations.value, + slots = slots + ) + + val sceneStrategy = remember { + MultiPanePaneSceneStrategy( state = state, - content = content + slots = slots, + currentPanedNavigationState = panedNavigationState::value, + content = content, + ) + } + + NavDisplay( + backStack = backStack, + modifier = modifier, + onBack = { count -> + val poppedBackStackIds = state.backStackTransform(navigationState) + .map(Node::id) + .dropLast(count) + + val poppedNavigationState = state.findNavigationStateMatching( + backstackIds = poppedBackStackIds, + ) + state.onPopped(poppedNavigationState) + }, + entryDecorators = listOf( + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + sceneStrategy = sceneStrategy, + entryProvider = { key -> + NavEntry( + key = key, + content = { destination -> + val scope = LocalPaneScope.current + @Suppress("UNCHECKED_CAST") + state.renderTransform(scope as PaneScope, destination) + }, + ) + }, + ) +} + + +@Stable +private class MultiPanePaneSceneStrategy( + private val state: MultiPaneDisplayState, + private val slots: Set, + private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, + private val content: @Composable (MultiPaneDisplayScope.() -> Unit), +) : SceneStrategy { + + @Composable + override fun calculateScene( + entries: List>, + onBack: (count: Int) -> Unit + ): Scene { + + val backstackIds = entries.map { it.key.id } + + return remember(backstackIds) { + + // Calculate the scene for the entries specified. + // Since there might be a predictive back gesture, pop until the right navigation state + // is found + val current = state.findNavigationStateMatching( + backstackIds = backstackIds, + ) + + val activeIds = state.destinationTransform(current) + .let { destination -> + destination.children.mapTo(mutableSetOf(), Node::id) + destination.id + } + + val poppedBackstackIds = state.backStackTransform(state.popTransform(current)) + .mapTo( + destination = mutableSetOf(), + transform = Node::id + ) + + MultiPaneDisplayScene( + backstackIds = backstackIds, + destination = state.destinationTransform(current), + slots = slots, + panesToDestinations = state.panesToDestinationsTransform, + currentPanedNavigationState = currentPanedNavigationState(), + entries = entries.filter { it.key.id in activeIds }, + previousEntries = entries.filter { it.key.id in poppedBackstackIds }, + scopeContent = content + ) + } + } +} + +private class MultiPaneDisplayScene( + override val entries: List>, + override val previousEntries: List>, + private val backstackIds: List, + private val destination: Destination, + private val slots: Set, + private val panesToDestinations: @Composable (Destination) -> Map, + private val currentPanedNavigationState: SlotBasedPanedNavigationState, + private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), +) : Scene { + + override val key: Any = destination.id + + override val content: @Composable () -> Unit = { + + val panedNavigationState by currentPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds = backstackIds, + panesToDestinations = panesToDestinations(destination), + slots = slots, ) + + val multiPaneDisplayScope: MultiPaneDisplayScope = remember { + object : MultiPaneDisplayScope { + + @Composable + override fun Destination(pane: Pane) { + val id = panedNavigationState.destinationFor(pane)?.id + val entry = entries.firstOrNull { it.key.id == id } ?: return + + val paneState = panedNavigationState.slotFor(pane) + ?.let(panedNavigationState::paneStateFor) ?: return + + val animatedContentScope = LocalNavAnimatedContentScope.current + + val scope = remember { + AnimatedPaneScope( + paneState = paneState, + activeState = derivedStateOf { + animatedContentScope.transition.targetState == EnterExitState.Visible + }, + animatedContentScope = animatedContentScope, + ) + }.also { it.paneState = paneState } + + CompositionLocalProvider( + LocalPaneScope provides scope + ) { + entry.content(entry.key) + } + } + + override fun adaptationsIn(pane: Pane): Set = + panedNavigationState.adaptationsIn(pane) + + override fun destinationIn(pane: Pane): Destination? = + panedNavigationState.destinationFor(pane) + } + } + multiPaneDisplayScope.scopeContent() } } + +private fun MultiPaneDisplayState<*, NavigationState, *>.findNavigationStateMatching( + backstackIds: List, +): NavigationState { + var state = navigationState.value + while (backStackTransform(state).map(Node::id) != backstackIds) { + state = popTransform(state) + } + return state +} + +@Composable +internal fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds: List, + panesToDestinations: Map, + slots: Set +): State> = + remember { + mutableStateOf(this) + }.also { + val backStackChanged = it.value.backStackIds != backStackIds + val paneMappingChanged = it.value.panesToDestinations != panesToDestinations + + if (backStackChanged || paneMappingChanged) { + it.value = it.value.adaptTo( + slots = slots, + panesToDestinations = panesToDestinations, + backStackIds = backStackIds, + ) + } + } + +private val LocalPaneScope = staticCompositionLocalOf> { + throw IllegalArgumentException( + "PaneScope should not be read until provided in the composition" + ) +} + diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt deleted file mode 100644 index 127da49..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayScene.kt +++ /dev/null @@ -1,294 +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.animation.EnterExitState -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.runtime.NavEntry -import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator -import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.ui.LocalNavAnimatedContentScope -import com.tunjid.treenav.compose.navigation3.ui.NavDisplay -import com.tunjid.treenav.compose.navigation3.ui.Scene -import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy -import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun MultiPaneDisplay2( - sharedTransitionScope: SharedTransitionScope, - state: MultiPaneDisplayState, - modifier: Modifier = Modifier, - content: @Composable (MultiPaneDisplayScope.() -> Unit), -) { - val navigationState by state.navigationState - val panesToDestinations = rememberUpdatedState( - state.panesToDestinationsTransform( - state.destinationTransform(navigationState) - ) - ) - - val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> - state.backStackTransform(navigationState).let { currentBackStack -> - val sameBackStack = currentBackStack == mutableBackStack - if (sameBackStack) return@let - - mutableBackStack.clear() - mutableBackStack.addAll(currentBackStack) - } - } - - val slots = remember { - List( - size = state.panes.size, - init = ::Slot - ).toSet() - } - - val initialPanedNavigationState = remember { - SlotBasedPanedNavigationState.initial(slots = slots) - .adaptTo( - slots = slots, - panesToDestinations = panesToDestinations.value, - backStackIds = backStack.map(Node::id), - ) - } - - val panedNavigationState = initialPanedNavigationState.rememberUpdatedPanedNavigationState( - backStackIds = backStack.map(Node::id), - panesToDestinations = panesToDestinations.value, - slots = slots - ) - - val sceneStrategy = remember { - MultiPanePaneSceneStrategy( - state = state, - slots = slots, - currentPanedNavigationState = panedNavigationState::value, - content = content, - ) - } - - NavDisplay( - backStack = backStack, - modifier = modifier, - onBack = { count -> - val poppedBackStackIds = state.backStackTransform(navigationState) - .map(Node::id) - .dropLast(count) - - val poppedNavigationState = state.findNavigationStateMatching( - backstackIds = poppedBackStackIds, - ) - - state.onPopped(poppedNavigationState) - }, - entryDecorators = listOf( - navEntryDecorator { entry -> - with(sharedTransitionScope) { - Box( - Modifier.sharedElement( - rememberSharedContentState(entry.key), - animatedVisibilityScope = LocalNavAnimatedContentScope.current, - ), - ) { - entry.content(entry.key) - } - } - }, - rememberSceneSetupNavEntryDecorator(), - rememberSavedStateNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - sceneStrategy = sceneStrategy, - entryProvider = { key -> - NavEntry( - key = key, - content = { destination -> - val scope = LocalPaneScope.current - @Suppress("UNCHECKED_CAST") - state.renderTransform(scope as PaneScope, destination) - }, - ) - }, - ) -} - -@Stable -private class MultiPanePaneSceneStrategy( - private val state: MultiPaneDisplayState, - private val slots: Set, - private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, - private val content: @Composable (MultiPaneDisplayScope.() -> Unit), -) : SceneStrategy { - - @Composable - override fun calculateScene( - entries: List>, - onBack: (count: Int) -> Unit - ): Scene { - - val backstackIds = entries.map { it.key.id } - - return remember(backstackIds) { - - // Calculate the scene for the entries specified. - // Since there might be a predictive back gesture, pop until the right navigation state - // is found - val current = state.findNavigationStateMatching( - backstackIds = backstackIds, - ) - - val activeIds = state.destinationTransform(current) - .let { destination -> - destination.children.mapTo(mutableSetOf(), Node::id) + destination.id - } - - val poppedBackstackIds = state.backStackTransform(state.popTransform(current)) - .mapTo( - destination = mutableSetOf(), - transform = Node::id - ) - - MultiPaneDisplayScene( - backstackIds = backstackIds, - destination = state.destinationTransform(current), - slots = slots, - panesToDestinations = state.panesToDestinationsTransform, - currentPanedNavigationState = currentPanedNavigationState(), - entries = entries.filter { it.key.id in activeIds }, - previousEntries = entries.filter { it.key.id in poppedBackstackIds }, - scopeContent = content - ) - } - } -} - -private class MultiPaneDisplayScene( - override val entries: List>, - override val previousEntries: List>, - private val backstackIds: List, - private val destination: Destination, - private val slots: Set, - private val panesToDestinations: @Composable (Destination) -> Map, - private val currentPanedNavigationState: SlotBasedPanedNavigationState, - private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), -) : Scene { - - override val key: Any = destination.id - - override val content: @Composable () -> Unit = { - - val panedNavigationState by currentPanedNavigationState.rememberUpdatedPanedNavigationState( - backStackIds = backstackIds, - panesToDestinations = panesToDestinations(destination), - slots = slots, - ) - - val multiPaneDisplayScope: MultiPaneDisplayScope = remember { - object : MultiPaneDisplayScope { - - @Composable - override fun Destination(pane: Pane) { - val id = panedNavigationState.destinationFor(pane)?.id - val entry = entries.firstOrNull { it.key.id == id } ?: return - - val paneState = panedNavigationState.slotFor(pane) - ?.let(panedNavigationState::paneStateFor) ?: return - - val animatedContentScope = LocalNavAnimatedContentScope.current - - val scope = remember { - AnimatedPaneScope( - paneState = paneState, - activeState = derivedStateOf { - animatedContentScope.transition.targetState == EnterExitState.Visible - }, - animatedContentScope = animatedContentScope, - ) - }.also { it.paneState = paneState } - - CompositionLocalProvider( - LocalPaneScope provides scope - ) { - entry.content(entry.key) - } - } - - override fun adaptationsIn(pane: Pane): Set = - panedNavigationState.adaptationsIn(pane) - - override fun destinationIn(pane: Pane): Destination? = - panedNavigationState.destinationFor(pane) - } - } - multiPaneDisplayScope.scopeContent() - } -} - -private fun MultiPaneDisplayState<*, NavigationState, *>.findNavigationStateMatching( - backstackIds: List, -): NavigationState { - var state = navigationState.value - while (backStackTransform(state).map(Node::id) != backstackIds) { - state = popTransform(state) - } - return state -} - -@Composable -private fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( - backStackIds: List, - panesToDestinations: Map, - slots: Set -): State> = - remember { - mutableStateOf(this) - }.also { - val backStackChanged = it.value.backStackIds != backStackIds - val paneMappingChanged = it.value.panesToDestinations != panesToDestinations - - if (backStackChanged || paneMappingChanged) { - it.value = it.value.adaptTo( - slots = slots, - panesToDestinations = panesToDestinations, - backStackIds = backStackIds, - ) - } - } - -private val LocalPaneScope = staticCompositionLocalOf> { - throw IllegalArgumentException( - "PaneScope should not be read until provided in the composition" - ) -} 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 8945f3b..79f4119 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 @@ -74,7 +74,7 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.me.mePaneEntry import com.tunjid.demo.common.ui.profile.profilePaneEntry import com.tunjid.treenav.MultiStackNav -import com.tunjid.treenav.compose.MultiPaneDisplay2 +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 @@ -107,10 +107,7 @@ fun App( ) } - MultiPaneDisplay2( - sharedTransitionScope = this, - modifier = Modifier - .fillMaxSize(), + MultiPaneDisplay( state = appState.rememberMultiPaneDisplayState( remember { listOf( @@ -144,6 +141,8 @@ fun App( ) }, ), + modifier = Modifier + .fillMaxSize(), ) { appState.displayScope = this appState.splitLayoutState.visibleCount = appState.filteredPaneOrder.size From f3399ee38c00c8cdcef6c1dd436a8126122cc3fc Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 20 Jun 2025 10:28:06 -0400 Subject: [PATCH 13/78] Match changes in upstream navigation 3 --- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 13 +- .../treenav/compose/MultiPaneDisplay.kt | 19 ++- .../MovableContentNavEntryDecorator.kt | 98 ------------ ...ansitionAwareLifecycleNavEntryDecorator.kt | 12 +- .../ViewModelStoreNavEntryDecorator.kt | 4 +- .../runtime/DecoratedNavEntryProvider.kt | 144 ++++++------------ .../compose/navigation3/runtime/NavEntry.kt | 39 ++++- .../navigation3/runtime/NavEntryDecorator.kt | 63 ++++++-- .../navigation3/runtime/NavEntryWrapper.kt | 12 +- .../runtime/SavedStateNavEntryDecorator.kt | 37 +++-- .../compose/navigation3/ui/DialogScene.kt | 11 +- .../compose/navigation3/ui/NavDisplay.kt | 28 ++-- .../ui/SceneSetupNavEntryDecorator.kt | 10 +- .../compose/navigation3/ui/SinglePaneScene.kt | 6 +- ...ansitionAwareLifecycleNavEntryDecorator.kt | 7 +- 15 files changed, 203 insertions(+), 300 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt index fa91501..ef6f31a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt @@ -37,12 +37,13 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Keys.id +import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.decorators.transitionAwareLifecycleNavEntryDecorator import com.tunjid.treenav.compose.navigation3.runtime.DecoratedNavEntryProvider import com.tunjid.treenav.compose.navigation3.runtime.NavEntry -import com.tunjid.treenav.compose.navigation3.decorators.rememberMovableContentNavEntryDecorator import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.transitionAwareLifecycleNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator @Composable internal fun DecoratedNavEntryMultiPaneDisplayScope( @@ -76,7 +77,7 @@ internal fun DecoratedNavEntr ) }, entryDecorators = listOf( - rememberMovableContentNavEntryDecorator(), + rememberSceneSetupNavEntryDecorator(), rememberSavedStateNavEntryDecorator(), transitionAwareLifecycleNavEntryDecorator( backStack = backStack, @@ -97,12 +98,12 @@ internal fun DecoratedNavEntr paneRenderer = { val currentEntry = remember(paneState.currentDestination?.id) { updatedEntries.findLast { - it.key.id == paneState.currentDestination?.id + it.id == paneState.currentDestination?.id } } checkNotNull(currentEntry) { "There is no entry for the current navigation destination with id ${paneState.currentDestination?.id}" - }.content(currentEntry.key) + }.Content() }, ) } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 52fb227..4fd8f88 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Keys.id import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator import com.tunjid.treenav.compose.navigation3.runtime.NavEntry import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator @@ -163,6 +164,9 @@ fun MultiPaneDisplay( entryProvider = { key -> NavEntry( key = key, + metadata = mapOf( + Keys.ID_KEY to key.id + ), content = { destination -> val scope = LocalPaneScope.current @Suppress("UNCHECKED_CAST") @@ -188,7 +192,7 @@ private class MultiPanePaneSceneStrategy Unit ): Scene { - val backstackIds = entries.map { it.key.id } + val backstackIds = entries.map { it.id } return remember(backstackIds) { @@ -216,8 +220,8 @@ private class MultiPanePaneSceneStrategy( @Composable override fun Destination(pane: Pane) { val id = panedNavigationState.destinationFor(pane)?.id - val entry = entries.firstOrNull { it.key.id == id } ?: return + val entry = entries.firstOrNull { it.id == id } ?: return val paneState = panedNavigationState.slotFor(pane) ?.let(panedNavigationState::paneStateFor) ?: return @@ -271,7 +275,7 @@ private class MultiPaneDisplayScene( CompositionLocalProvider( LocalPaneScope provides scope ) { - entry.content(entry.key) + entry.Content() } } @@ -323,3 +327,8 @@ private val LocalPaneScope = staticCompositionLocalOf> { ) } +internal object Keys { + val ID_KEY = "com.tunjid.treenav.compose.id" + + val NavEntry<*>.id get() = metadata[ID_KEY] as String +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt deleted file mode 100644 index e0aafaa..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt +++ /dev/null @@ -1,98 +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.navigation3.decorators - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.key -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator - -/** Returns a [MovableContentNavEntryDecorator] that is remembered across recompositions. */ -@Composable -internal fun rememberMovableContentNavEntryDecorator(): NavEntryDecorator = remember { - MovableContentNavEntryDecorator() -} - -/** - * A [NavEntryDecorator] that wraps each entry in a [movableContentOf] to allow nav displays to - * arbitrarily place entries in different places in the composable call hierarchy and ensures that - * the same entry content is not composed multiple times in different places of the hierarchy. - * - * This should likely be the first [NavEntryDecorator] to ensure that other [NavEntryDecorator] - * calls that are stateful are moved properly inside the [movableContentOf]. - */ -private fun MovableContentNavEntryDecorator(): NavEntryDecorator { - val movableContentContentHolderMap: MutableMap Unit>> = - mutableMapOf() - val movableContentHolderMap: MutableMap Unit> = mutableMapOf() - return navEntryDecorator( - onPop = { - movableContentHolderMap.remove(it) - movableContentContentHolderMap.remove(it) - }, - decorator = { entry -> - val key = entry.key - movableContentContentHolderMap.getOrPut(key) { - key(key) { - remember { - mutableStateOf( - @Composable { - error( - "Should not be called, this should always be updated in" + - "DecorateEntry with the real content" - ) - } - ) - } - } - } - movableContentHolderMap.getOrPut(key) { - key(key) { - remember { - movableContentOf { - // In case the key is removed from the backstack while this is still - // being rendered, we remember the MutableState directly to allow - // rendering it while we are animating out. - remember { movableContentContentHolderMap.getValue(key) }.value() - } - } - } - } - - key(key) { - // In case the key is removed from the backstack while this is still - // being rendered, we remember the MutableState directly to allow - // updating it while we are animating out. - val movableContentContentHolder = remember { - movableContentContentHolderMap.getValue(key) - } - // Update the state holder with the actual entry content - movableContentContentHolder.value = { entry.content(key) } - // In case the key is removed from the backstack while this is still - // being rendered, we remember the movableContent directly to allow - // rendering it while we are animating out. - val movableContentHolder = remember { movableContentHolderMap.getValue(key) } - // Finally, render the entry content via the movableContentOf - movableContentHolder() - } - } - ) -} diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt index 218df85..5357e4f 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt @@ -33,8 +33,8 @@ import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator internal fun transitionAwareLifecycleNavEntryDecorator( backStack: List, isSettled: @Composable () -> Boolean -) = navEntryDecorator { entry -> - val isInBackStack = entry.key in backStack +) = navEntryDecorator { entry -> + val isInBackStack = entry.isInBackStack(backStack) val settled = isSettled() val maxLifecycle = when { @@ -42,14 +42,14 @@ internal fun transitionAwareLifecycleNavEntryDecorator( isInBackStack && !settled -> Lifecycle.State.STARTED else /* !isInBackStack */ -> Lifecycle.State.CREATED } - LifecycleOwner(maxLifecycle = maxLifecycle) { entry.content.invoke(entry.key) } + LifecycleOwner(maxLifecycle = maxLifecycle) { entry.Content() } } @Composable private fun LifecycleOwner( maxLifecycle: Lifecycle.State = Lifecycle.State.RESUMED, parentLifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { val childLifecycleOwner = remember(parentLifecycleOwner) { ChildLifecycleOwner() } // Pass LifecycleEvents from the parent down to the child @@ -67,9 +67,7 @@ private fun LifecycleOwner( childLifecycleOwner.maxLifecycle = maxLifecycle } // Now install the LifecycleOwner as a composition local - CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) { - content.invoke() - } + CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) { content.invoke() } } private class ChildLifecycleOwner : LifecycleOwner { diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt index 47e97ed..daa1bc4 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt @@ -90,7 +90,7 @@ internal fun ViewModelStoreNavEntryDecorator( } } return navEntryDecorator(onPop) { entry -> - val viewModelStore = storeOwnerProvider.viewModelStoreForKey(entry.key) + val viewModelStore = storeOwnerProvider.viewModelStoreForKey(entry.contentKey) val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current val childViewModelStoreOwner = remember { @@ -123,7 +123,7 @@ internal fun ViewModelStoreNavEntryDecorator( } } CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelStoreOwner) { - entry.content.invoke(entry.key) + entry.Content() } } } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt index 5df7e1e..3756fc6 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt @@ -16,13 +16,12 @@ package com.tunjid.treenav.compose.navigation3.runtime - import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.key +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.staticCompositionLocalOf import kotlin.jvm.JvmSuppressWildcards @@ -33,6 +32,7 @@ import kotlin.jvm.JvmSuppressWildcards * Note: the order in which the [NavEntryDecorator]s are added to the list determines their scope, * i.e. a [NavEntryDecorator] added earlier in a list has its data available to those added later. * + * @param T the type of the backStack key * @param backStack the list of keys that represent the backstack * @param entryDecorators the [NavEntryDecorator]s that are providing data to the content * @param entryProvider a function that returns the [NavEntry] for a given key @@ -44,23 +44,21 @@ internal fun DecoratedNavEntryProvider( entryProvider: (key: T) -> NavEntry, entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<*>> = listOf(rememberSavedStateNavEntryDecorator()), - content: @Composable (List>) -> Unit + content: @Composable (List>) -> Unit, ) { // Kotlin does not know these things are compatible so we need this explicit cast // to ensure our lambda below takes the correct type entryProvider as (T) -> NavEntry - - // Generates a list of entries that are wrapped with the given providers val entries = - backStack.map { - val entry = entryProvider.invoke(it) + backStack.mapIndexed { index, key -> + val entry = entryProvider.invoke(key) decorateEntry(entry, entryDecorators as List>) } // Provides the entire backstack to the previously wrapped entries val initial: @Composable () -> Unit = remember(entries) { { content(entries) } } - PrepareBackStack(backStack, entryDecorators, initial) + PrepareBackStack(entries, entryDecorators, initial) } /** @@ -78,62 +76,28 @@ internal fun decorateEntry( ): NavEntry { val initial = object : NavEntryWrapper(entry) { - override val content: @Composable ((T) -> Unit) = { - val key = entry.key - // Tracks whether the key is changed - var keyChanged = false + @Composable + override fun Content() { val localInfo = LocalNavEntryDecoratorLocalInfo.current - val keyIds = localInfo.keyIds[key] - val lastId = keyIds!!.last() - var id: Int = - rememberSaveable(keyIds.last()) { - keyChanged = true - lastId - } - id = - rememberSaveable(keyIds.size) { - // if the key changed, use the current id - // If the key was not changed, and the current id is not in composition - // or on - // the - // back - // stack then update the id with the last item from the backstack with - // the - // associated - // key. This ensures that we can handle duplicates, both consecutive and - // non-consecutive - if ( - !keyChanged && - (!localInfo.idsInComposition.contains(id) || keyIds.contains(id)) - ) { - lastId - } else { - id - } - } - - keyChanged = false + val idsInComposition = localInfo.idsInComposition // store onPop for every decorator that has ever decorated this entry // so that onPop will be called for newly added or removed decorators as well val popCallbacks = remember { LinkedHashSet<(Any) -> Unit>() } - DisposableEffect(key1 = key) { - localInfo.idsInComposition.add(id) + DisposableEffect(key1 = contentKey) { + idsInComposition.add(contentKey) onDispose { - val notInComposition = localInfo.idsInComposition.remove(id) - val popped = !localInfo.keyIds.contains(key) - if (notInComposition && popped) { + val notInComposition = idsInComposition.remove(contentKey) + val popped = !localInfo.contentKeys.contains(contentKey) + if (popped && notInComposition) { + // we reverse the scopes before popping to imitate the order // of onDispose calls if each scope/decorator had their own // onDispose // calls for clean up // convert to mutableList first for backwards compat. - popCallbacks.toMutableList().reversed().forEach { it(key) } - // If the refCount is 0, remove the key from the refCount. - if (localInfo.keyIds[key]?.isEmpty() == true) { - localInfo.keyIds.remove(key) - } + popCallbacks.toMutableList().reversed().forEach { it(contentKey) } } } } @@ -153,50 +117,38 @@ internal fun decorateEntry( * 2. have never been composed (i.e. never invoked with [DecorateNavEntry]) */ @Composable -internal fun PrepareBackStack( - backStack: List, +internal fun PrepareBackStack( + entries: List>, decorators: List>, content: @Composable (() -> Unit), ) { val localInfo = remember { NavEntryDecoratorLocalInfo() } + val contentKeys = localInfo.contentKeys - DisposableEffect(key1 = backStack) { onDispose { localInfo.keyIds.clear() } } - - backStack.forEachIndexed { index, key -> - val id = getIdForEntry(key, index) - localInfo.keyIds.getOrPut(key) { LinkedHashSet() }.add(id) - + // update this backStack so that onDispose has access to the latest backStack to check + // if an entry has been popped + val latestBackStack by rememberUpdatedState(entries.map { it.contentKey }) + latestBackStack.forEach { contentKey -> + contentKeys.add(contentKey) // store onPop for every decorator has ever decorated this key // so that onPop will be called for newly added or removed decorators as well - val popCallbacks = remember(key) { LinkedHashSet<(Any) -> Unit>() } + val popCallbacks = remember(contentKey) { LinkedHashSet<(Any) -> Unit>() } decorators.distinct().forEach { popCallbacks.add(it.onPop) } - key(key) { - DisposableEffect(key) { - // We update here as part of composition to ensure the value is available to - // ProvideToEntry - localInfo.keyIds.getOrPut(key) { LinkedHashSet() }.add(id) - onDispose { - // If the backStack count is less than the refCount for the key, remove the - // state since that means we removed a key from the backstack, and set the - // refCount to the backstack count. - val backstackCount = backStack.count { it == key } - val lastKeyCount = localInfo.keyIds[key]?.size ?: 0 - if (backstackCount < lastKeyCount) { - // if popped, remove id from set of ids for this key - localInfo.keyIds[key]!!.remove(id) - // run onPop callback - if (!localInfo.idsInComposition.contains(id)) { - // we reverse the order before popping to imitate the order - // of onDispose calls if each scope/decorator had their own onDispose - // calls for clean up. convert to mutableList first for backwards compat. - popCallbacks.toMutableList().reversed().forEach { it(key) } - } - } - // If the refCount is 0, remove the key from the refCount. - if (localInfo.keyIds[key]?.isEmpty() == true) { - localInfo.keyIds.remove(key) - } + DisposableEffect(contentKey) { + onDispose { + val originalRoot = entries.first().contentKey + val sameBackStack = originalRoot == latestBackStack.first() + val popped = + if (sameBackStack && !latestBackStack.contains(contentKey)) { + contentKeys.remove(contentKey) + } else false + // run onPop callback + if (popped && !localInfo.idsInComposition.contains(contentKey)) { + // we reverse the order before popping to imitate the order + // of onDispose calls if each scope/decorator had their own onDispose + // calls for clean up. convert to mutableList first for backwards compat. + popCallbacks.toMutableList().reversed().forEach { it(contentKey) } } } } @@ -205,17 +157,9 @@ internal fun PrepareBackStack( } private class NavEntryDecoratorLocalInfo { - val keyIds: MutableMap> = mutableMapOf() - - @Suppress("PrimitiveInCollection") // The order of the element matters - val idsInComposition: LinkedHashSet = LinkedHashSet() + val contentKeys: MutableSet = mutableSetOf() + val idsInComposition: MutableSet = mutableSetOf() val popCallbacks: LinkedHashMap Unit> = LinkedHashMap() - - fun populatePopMap(decorators: List>) { - decorators.reversed().forEach { decorator -> - popCallbacks.getOrPut(decorator.hashCode(), decorator::onPop) - } - } } private val LocalNavEntryDecoratorLocalInfo = @@ -224,6 +168,4 @@ private val LocalNavEntryDecoratorLocalInfo = "CompositionLocal LocalProviderLocalInfo not present. You must call " + "ProvideToBackStack before calling ProvideToEntry." ) - } - -private fun getIdForEntry(key: Any, count: Int): Int = 31 * key.hashCode() + count \ No newline at end of file + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt index ba49caa..a6f5234 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt @@ -14,20 +14,49 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3 -.runtime +package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable +import androidx.annotation.RestrictTo /** * Entry maintains and stores the key and the content represented by that key. Entries should be * created as part of a [NavDisplay.entryProvider](reference/androidx/navigation/NavDisplay). * + * @param T the type of the key for this NavEntry * @param key key for this entry + * @param contentKey A unique, stable id that uniquely identifies the content of this NavEntry. To + * maximize stability, it should ge derived from the [key]. The contentKey type must be saveable + * (i.e. on Android, it should be saveable via Android). Defaults to [key].toString(). * @param metadata provides information to the display * @param content content for this entry to be displayed when this entry is active */ internal open class NavEntry( - open val key: T, + private val key: T, + val contentKey: Any = defaultContentKey(key), open val metadata: Map = emptyMap(), - open val content: @Composable (T) -> Unit -) + private val content: @Composable (T) -> Unit, +) { + /** Allows creating a NavEntry from another NavEntry while keeping [content] field private */ + internal constructor( + navEntry: NavEntry + ) : this(navEntry.key, navEntry.contentKey, navEntry.metadata, navEntry.content) + + /** + * Invokes the composable content of this NavEntry with the key that was provided when + * instantiating this NavEntry + */ + @Composable + open fun Content() { + this.content(key) + } + + /** + * Returns true if this NavEntry is in the [backStack], false otherwise. + * + * @param [backStack] the backStack to check if it contains this NavEntry. + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + fun isInBackStack(backStack: List): Boolean = backStack.contains(this.key) +} + +@PublishedApi internal fun defaultContentKey(key: Any): Any = key.toString() diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt index e82ae15..bf70004 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt @@ -16,51 +16,82 @@ package com.tunjid.treenav.compose.navigation3.runtime + import androidx.compose.runtime.Composable import kotlin.jvm.JvmSuppressWildcards -/** Marker class to hold the onPop and decorator functions that will be invoked at runtime. */ +/** + * Marker class to hold the onPop and decorator functions that will be invoked at runtime. + * + * See documentation on [androidx.navigation3.runtime.navEntryDecorator] for more info. + */ internal class NavEntryDecorator internal constructor( internal val onPop: (key: Any) -> Unit, - internal val navEntryDecorator: @Composable (entry: NavEntry) -> Unit + internal val navEntryDecorator: @Composable (entry: NavEntry) -> Unit, ) /** - * Function to provide information to all the [NavEntry] that are integrated with a - * [DecoratedNavEntryProvider]. + * Function to decorate the [NavEntry] that are integrated with a [DecoratedNavEntryProvider]. * - * @param onPop a callback that provides the key of a [NavEntry] that has been popped from the - * backStack and is leaving composition. This optional callback should to be used to clean up - * states that were used to decorate the NavEntry3 - * @param decorator the composable function to provide information to a [NavEntry] [decorator]. Note - * that this function only gets invoked for NavEntries that are actually getting rendered (i.e. by - * invoking the [NavEntry.content].) + * Primary usages include but are not limited to: + * 1. provide information to entries with [androidx.compose.runtime.CompositionLocal], i.e. + * + * ``` + * val decorator = navEntryDecorator { entry -> + * ... + * CompositionLocalProvider(LocalMyStateProvider provides myState) { + * entry.content.invoke(entry.key) + * } + * } + * ``` + * 2. Wrap entry content with other composable content + * + * ``` + * val decorator = navEntryDecorator { entry -> + * ... + * MyComposableFunction { + * entry.content.invoke(entry.key) + * } + * } + * ``` + * + * @param T the type of the backStack key + * @param onPop the callback to clean up the decorator state for a [NavEntry] when the entry is + * popped from the backstack and is leaving composition.The lambda provides the [NavEntry.key] of + * the popped entry as input. + * @param [decorator] the composable function to decorate a [NavEntry]. Note that this function only + * gets invoked for NavEntries that are actually getting rendered (i.e. by invoking the + * [NavEntry.content].) */ internal fun navEntryDecorator( - onPop: (key: Any) -> Unit = {}, - decorator: @Composable (entry: NavEntry) -> Unit + onPop: (contentKey: Any) -> Unit = {}, + decorator: @Composable (entry: NavEntry) -> Unit, ): NavEntryDecorator = NavEntryDecorator(onPop, decorator) /** * Wraps a [NavEntry] with the list of [NavEntryDecorator] in the order that the decorators were * added to the list and invokes the content of the wrapped entry. + * + * @param T the type of the backStack key + * @param entry the [NavEntry] to wrap + * @param entryDecorators the list of decorators to wrap the [entry] with */ @Composable internal fun DecorateNavEntry( entry: NavEntry, - entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<*>> + entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<*>>, ) { @Suppress("UNCHECKED_CAST") (entryDecorators as List<@JvmSuppressWildcards NavEntryDecorator>) .distinct() .foldRight(initial = entry) { decorator, wrappedEntry -> object : NavEntryWrapper(wrappedEntry) { - override val content: @Composable ((T) -> Unit) = { + @Composable + override fun Content() { decorator.navEntryDecorator(wrappedEntry) } } } - .content - .invoke(entry.key) + .Content() } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt index 8665c47..1b470a0 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt @@ -23,16 +23,8 @@ import androidx.compose.runtime.Composable * * This provides a nesting mechanism for [NavEntry]s that allows properly nested content. * + * @param T the type of the backStack key * @param navEntry the [NavEntry] to wrap */ internal open class NavEntryWrapper(val navEntry: NavEntry) : - NavEntry(navEntry.key, navEntry.metadata, navEntry.content) { - override val key: T - get() = navEntry.key - - override val metadata: Map - get() = navEntry.metadata - - override val content: @Composable (T) -> Unit - get() = navEntry.content -} + NavEntry(navEntry) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt index 2504805..ee7ec94 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt @@ -34,7 +34,6 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.savedState - /** * Returns a [SavedStateNavEntryDecorator] that is remembered across recompositions. * @@ -53,21 +52,24 @@ internal fun rememberSavedStateNavEntryDecorator( * * This [NavEntryDecorator] is the only one that is **required** as saving state is considered a * non-optional feature. + * + * @param saveableStateHolder the [SaveableStateHolder] that holds the state defined with + * [rememberSaveable]. A saved state can only be restored from the [SaveableStateHolder] that it + * was saved with. */ -private fun SavedStateNavEntryDecorator( +internal fun SavedStateNavEntryDecorator( saveableStateHolder: SaveableStateHolder ): NavEntryDecorator { - val registryMap = mutableMapOf() + val registryMap = mutableMapOf() - val onPop: (Any) -> Unit = { key -> - val id = getIdForKey(key) - if (registryMap.contains(id)) { + val onPop: (Any) -> Unit = { contentKey -> + if (registryMap.contains(contentKey)) { // saveableStateHolder onPop - saveableStateHolder.removeState(id) + saveableStateHolder.removeState(contentKey) // saved state onPop val savedState = savedState() - val childRegistry = registryMap.getValue(id) + val childRegistry = registryMap.getValue(contentKey) childRegistry.savedStateRegistryController.performSave(savedState) childRegistry.savedState = savedState childRegistry.lifecycle.currentState = Lifecycle.State.DESTROYED @@ -75,34 +77,29 @@ private fun SavedStateNavEntryDecorator( } return navEntryDecorator(onPop = onPop) { entry -> - val key = entry.key - val id = getIdForKey(key) - val childRegistry by rememberSaveable( - key, + entry.contentKey, stateSaver = Saver( save = { it.savedState }, - restore = { EntrySavedStateRegistry().apply { savedState = it } } - ) + restore = { EntrySavedStateRegistry().apply { savedState = it } }, + ), ) { mutableStateOf(EntrySavedStateRegistry()) } - registryMap.put(id, childRegistry) + registryMap.put(entry.contentKey, childRegistry) - saveableStateHolder.SaveableStateProvider(id) { + saveableStateHolder.SaveableStateProvider(entry.contentKey) { CompositionLocalProvider(LocalSavedStateRegistryOwner provides childRegistry) { - entry.content(key) + entry.Content() } } childRegistry.lifecycle.currentState = Lifecycle.State.RESUMED } } -private fun getIdForKey(key: Any): String = "${key::class.qualifiedName}:$key" - -private class EntrySavedStateRegistry : SavedStateRegistryOwner { +internal class EntrySavedStateRegistry : SavedStateRegistryOwner { override val lifecycle: LifecycleRegistry = LifecycleRegistry(this) val savedStateRegistryController: SavedStateRegistryController = SavedStateRegistryController.create(this) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt index 5b71f5c..39f303a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt @@ -22,9 +22,10 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.tunjid.treenav.compose.navigation3.runtime.NavEntry + /** An [OverlayScene] that renders an [entry] within a [Dialog]. */ internal class DialogScene( - override val key: T, + override val key: Any, override val previousEntries: List>, override val overlaidEntries: List>, private val entry: NavEntry, @@ -35,9 +36,7 @@ internal class DialogScene( override val entries: List> = listOf(entry) override val content: @Composable (() -> Unit) = { - Dialog(onDismissRequest = { onBack(1) }, properties = dialogProperties) { - entry.content.invoke(entry.key) - } + Dialog(onDismissRequest = { onBack(1) }, properties = dialogProperties) { entry.Content() } } } @@ -49,7 +48,7 @@ internal class DialogScene( */ internal class DialogSceneStrategy() : SceneStrategy { @Composable - public override fun calculateScene( + override fun calculateScene( entries: List>, onBack: (count: Int) -> Unit, ): Scene? { @@ -57,7 +56,7 @@ internal class DialogSceneStrategy() : SceneStrategy { val dialogProperties = lastEntry?.metadata?.get(DIALOG_KEY) as? DialogProperties return dialogProperties?.let { properties -> DialogScene( - key = lastEntry.key, + key = lastEntry.contentKey, previousEntries = entries.dropLast(1), overlaidEntries = entries.dropLast(1), entry = lastEntry, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt index 6eec0f4..9820a0e 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt @@ -62,7 +62,7 @@ internal object NavDisplay { * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that the content * should be animated using the provided [ContentTransform]. */ - public fun transitionSpec( + fun transitionSpec( transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? ): Map = mapOf(TRANSITION_SPEC to transitionSpec) @@ -70,7 +70,7 @@ internal object NavDisplay { * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that, when * popping from backstack, the content should be animated using the provided [ContentTransform]. */ - public fun popTransitionSpec( + fun popTransitionSpec( popTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? ): Map = mapOf(POP_TRANSITION_SPEC to popTransitionSpec) @@ -79,11 +79,11 @@ internal object NavDisplay { * popping from backstack using a Predictive back gesture, the content should be animated using * the provided [ContentTransform]. */ - public fun predictivePopTransitionSpec( + fun predictivePopTransitionSpec( predictivePopTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? ): Map = mapOf(PREDICTIVE_POP_TRANSITION_SPEC to predictivePopTransitionSpec) - public val defaultPredictivePopTransitionSpec: + val defaultPredictivePopTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = { ContentTransform( @@ -127,6 +127,7 @@ internal object NavDisplay { * entries to pop from the end of the backstack, as calculated by the [sceneStrategy]. * @param entryDecorators list of [NavEntryDecorator] to add information to the entry content * @param sceneStrategy the [SceneStrategy] to determine which scene to render a list of entries. + * @param sizeTransform the [SizeTransform] for the [AnimatedContent]. * @param transitionSpec Default [ContentTransform] when navigating to [NavEntry]s. * @param popTransitionSpec Default [ContentTransform] when popping [NavEntry]s. * @param predictivePopTransitionSpec Default [ContentTransform] when popping with predictive back @@ -242,11 +243,11 @@ internal fun NavDisplay( val sceneToRenderableEntryMap = remember( mostRecentSceneKeys.toList(), - scenes.values.map { scene -> scene.entries.map(NavEntry::key) }, + scenes.values.map { scene -> scene.entries.map(NavEntry::contentKey) }, transition.targetState, ) { buildMap { - val coveredEntryKeys = mutableSetOf() + val coveredEntryKeys = mutableSetOf() (mostRecentSceneKeys.filter { it != transition.targetState } + listOf(transition.targetState)) .fastForEachReversed { sceneKey -> @@ -254,23 +255,26 @@ internal fun NavDisplay( put( sceneKey, scene.entries - .map { it.key } + .map { it.contentKey } .filterNot(coveredEntryKeys::contains) .toSet(), ) - scene.entries.forEach { coveredEntryKeys.add(it.key) } + scene.entries.forEach { coveredEntryKeys.add(it.contentKey) } } } } // Transition Handling /** Keep track of the previous entries for the transition's current scene. */ - /** Keep track of the previous entries for the transition's current scene. */ val transitionCurrentStateEntries = remember(transition.currentState) { entries.toList() } // Consider this a pop if the current entries match the previous entries we have recorded // from the current state of the transition - val isPop = isPop(transitionCurrentStateEntries.map { it.key }, entries.map { it.key }) + val isPop = + isPop( + transitionCurrentStateEntries.map { it.contentKey }, + entries.map { it.contentKey }, + ) val zIndices = remember { mutableObjectFloatMapOf, Any>>() } val initialKey = transition.currentState @@ -339,12 +343,10 @@ internal fun NavDisplay( transitionEntry.contentTransform(PREDICTIVE_POP_TRANSITION_SPEC)?.invoke(this) ?: predictivePopTransitionSpec(this) } - isPop -> { transitionEntry.contentTransform(POP_TRANSITION_SPEC)?.invoke(this) ?: popTransitionSpec(this) } - else -> { transitionEntry.contentTransform(TRANSITION_SPEC)?.invoke(this) ?: transitionSpec(this) @@ -402,7 +404,7 @@ internal fun NavDisplay( // Show all OverlayScene instances above the AnimatedContent overlayScenes.fastForEachReversed { overlayScene -> // TODO Calculate what entries should be displayed from sceneToRenderableEntryMap - val allEntries = overlayScene.entries.map { it.key }.toSet() + val allEntries = overlayScene.entries.map { it.contentKey }.toSet() CompositionLocalProvider(LocalEntriesToRenderInCurrentScene provides allEntries) { overlayScene.content.invoke() } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt index 6e238fc..d02af78 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt @@ -47,7 +47,7 @@ internal fun SceneSetupNavEntryDecorator(): NavEntryDecorator { mutableMapOf() val movableContentHolderMap: MutableMap Unit> = mutableMapOf() return navEntryDecorator { entry -> - val key = entry.key + val key = entry.contentKey movableContentContentHolderMap.getOrPut(key) { key(key) { remember { @@ -75,7 +75,7 @@ internal fun SceneSetupNavEntryDecorator(): NavEntryDecorator { } } - if (LocalEntriesToRenderInCurrentScene.current.contains(entry.key)) { + if (LocalEntriesToRenderInCurrentScene.current.contains(key)) { key(key) { // In case the key is removed from the backstack while this is still // being rendered, we remember the MutableState directly to allow @@ -84,7 +84,7 @@ internal fun SceneSetupNavEntryDecorator(): NavEntryDecorator { movableContentContentHolderMap.getValue(key) } // Update the state holder with the actual entry content - movableContentContentHolder.value = { entry.content(key) } + movableContentContentHolder.value = { entry.Content() } // In case the key is removed from the backstack while this is still // being rendered, we remember the movableContent directly to allow // rendering it while we are animating out. @@ -98,9 +98,9 @@ internal fun SceneSetupNavEntryDecorator(): NavEntryDecorator { /** * The entry keys to render in the current [Scene], in the sense of the target of the animation for - * an [AnimatedContent] that is transitioning between different scenes. + * an [androidx.compose.animation.AnimatedContent] that is transitioning between different scenes. */ -public val LocalEntriesToRenderInCurrentScene: ProvidableCompositionLocal> = +internal val LocalEntriesToRenderInCurrentScene: ProvidableCompositionLocal> = compositionLocalOf { throw IllegalStateException( "Unexpected access to LocalEntriesToRenderInCurrentScene. You should only " + diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt index f890b32..d2a4f6d 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt @@ -20,13 +20,13 @@ import androidx.compose.runtime.Composable import com.tunjid.treenav.compose.navigation3.runtime.NavEntry internal data class SinglePaneScene( - override val key: T, + override val key: Any, val entry: NavEntry, override val previousEntries: List>, ) : Scene { override val entries: List> = listOf(entry) - override val content: @Composable () -> Unit = { entry.content.invoke(entry.key) } + override val content: @Composable () -> Unit = { entry.Content() } } /** @@ -37,7 +37,7 @@ internal class SinglePaneSceneStrategy : SceneStrategy { @Composable override fun calculateScene(entries: List>, onBack: (Int) -> Unit): Scene = SinglePaneScene( - key = entries.last().key, + key = entries.last().contentKey, entry = entries.last(), previousEntries = entries.dropLast(1), ) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt index 938050b..4128292 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt @@ -29,17 +29,18 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.compose.LocalLifecycleOwner import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator + @Composable internal fun transitionAwareLifecycleNavEntryDecorator(backStack: List, isSettled: Boolean) = - navEntryDecorator { entry -> - val isInBackStack = entry.key in backStack + navEntryDecorator { entry -> + val isInBackStack = entry.isInBackStack(backStack) val maxLifecycle = when { isInBackStack && isSettled -> Lifecycle.State.RESUMED isInBackStack && !isSettled -> Lifecycle.State.STARTED else /* !isInBackStack */ -> Lifecycle.State.CREATED } - LifecycleOwner(maxLifecycle = maxLifecycle) { entry.content.invoke(entry.key) } + LifecycleOwner(maxLifecycle = maxLifecycle) { entry.Content() } } @Composable From e7acd14d6eb11117d22e8e94cea2d28999ba9c19 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 20 Jun 2025 10:33:13 -0400 Subject: [PATCH 14/78] Delete DecoratedNavEntryMultiPaneDisplayScope --- .../DecoratedNavEntryMultiPaneDisplayScope.kt | 241 ------------------ 1 file changed, 241 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt deleted file mode 100644 index ef6f31a..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ /dev/null @@ -1,241 +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.animation.AnimatedContent -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.updateTransition -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.Keys.id -import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.transitionAwareLifecycleNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.runtime.DecoratedNavEntryProvider -import com.tunjid.treenav.compose.navigation3.runtime.NavEntry -import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator - -@Composable -internal fun DecoratedNavEntryMultiPaneDisplayScope( - state: MultiPaneDisplayState, - content: @Composable (MultiPaneDisplayScope.() -> Unit), -) { - val navigationState by state.navigationState - val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> - state.backStackTransform(navigationState).let { currentBackStack -> - val sameBackStack = currentBackStack == mutableBackStack - if (sameBackStack) return@let - - mutableBackStack.clear() - mutableBackStack.addAll(currentBackStack) - } - } - val panesToDestinations = state.panesToDestinationsTransform( - state.destinationTransform(navigationState) - ) - - DecoratedNavEntryProvider( - backStack = backStack, - entryProvider = { node -> - NavEntry( - key = node, - content = { destination -> - val scope = LocalPaneScope.current - @Suppress("UNCHECKED_CAST") - state.renderTransform(scope as PaneScope, destination) - } - ) - }, - entryDecorators = listOf( - rememberSceneSetupNavEntryDecorator(), - rememberSavedStateNavEntryDecorator(), - transitionAwareLifecycleNavEntryDecorator( - backStack = backStack, - isSettled = { - val scope = LocalPaneScope.current - scope.transition.currentState == scope.transition.targetState - } - ), - rememberViewModelStoreNavEntryDecorator(), - ), - content = { entries -> - val updatedEntries by rememberUpdatedState(entries) - val displayScope = remember { - DecoratedNavEntryMultiPaneDisplayScope( - panes = state.panes, - initialBackStack = backStack, - initialPanesToDestinations = panesToDestinations, - paneRenderer = { - val currentEntry = remember(paneState.currentDestination?.id) { - updatedEntries.findLast { - it.id == paneState.currentDestination?.id - } - } - checkNotNull(currentEntry) { - "There is no entry for the current navigation destination with id ${paneState.currentDestination?.id}" - }.Content() - }, - ) - } - DisposableEffect(navigationState, panesToDestinations) { - displayScope.onBackStackChanged( - backStackIds = backStack.map { it.id }, - panesToDestinations = panesToDestinations - ) - onDispose { } - } - - displayScope.content() - }, - ) -} - -@Stable -private class DecoratedNavEntryMultiPaneDisplayScope( - panes: List, - initialBackStack: List, - initialPanesToDestinations: Map, - private val paneRenderer: @Composable (PaneScope.() -> Unit), -) : MultiPaneDisplayScope { - - private val slots = List( - size = panes.size, - init = ::Slot - ).toSet() - - var panedNavigationState by mutableStateOf( - value = SlotBasedPanedNavigationState.initial(slots = slots) - .adaptTo( - slots = slots, - panesToDestinations = initialPanesToDestinations, - backStackIds = initialBackStack.map { it.id }, - ) - ) - - private val slotsToRoutes = - mutableStateMapOf Unit>().also { map -> - map[null] = {} - slots.forEach { slot -> - map[slot] = movableContentOf { Render(slot) } - } - } - - @Composable - override fun Destination(pane: Pane) { - val slot = panedNavigationState.slotFor(pane) - slotsToRoutes[slot]?.invoke() - } - - override fun adaptationsIn( - pane: Pane, - ): Set = panedNavigationState.adaptationsIn(pane) - - override fun destinationIn( - pane: Pane, - ): Destination? = panedNavigationState.destinationFor(pane) - - fun onBackStackChanged( - backStackIds: List, - panesToDestinations: Map, - ) { - updateAdaptiveNavigationState { - adaptTo( - slots = slots.toSet(), - panesToDestinations = panesToDestinations, - backStackIds = backStackIds, - ) - } - } - - /** - * Renders [slot] into its pane with scopes that allow for animations - * and shared elements. - */ - @Composable - private fun Render( - slot: Slot, - ) { - val paneTransition = updateTransition( - targetState = panedNavigationState.paneStateFor(slot), - label = "$slot-PaneTransition", - ) - paneTransition.AnimatedContent( - contentKey = { it.currentDestination?.id }, - transitionSpec = { - ContentTransform( - targetContentEnter = EnterTransition.None, - initialContentExit = ExitTransition.None, - sizeTransform = null, - ) - } - ) { targetPaneState -> - val scope = remember { - AnimatedPaneScope( - paneState = targetPaneState, - activeState = derivedStateOf { - val activePaneState = panedNavigationState.paneStateFor(slot) - activePaneState.currentDestination?.id == targetPaneState.currentDestination?.id - }, - animatedContentScope = this@AnimatedContent, - ) - } - - // While technically a backwards write, it stabilizes and ensures the values are - // correct at first composition - scope.paneState = targetPaneState - - val destination = targetPaneState.currentDestination - if (destination != null) { - CompositionLocalProvider( - LocalPaneScope provides scope - ) { - scope.paneRenderer() - } - } - } - } - - private inline fun updateAdaptiveNavigationState( - block: SlotBasedPanedNavigationState.() -> SlotBasedPanedNavigationState, - ) { - panedNavigationState = panedNavigationState.block() - } -} - -private val LocalPaneScope = staticCompositionLocalOf> { - throw IllegalArgumentException( - "PaneScope should not be read until provided in the composition" - ) -} - - From ec66b1300c2886d89debc0192b750f445739bfa6 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 13:21:22 -0400 Subject: [PATCH 15/78] Start to streamline APIs --- gradle/libs.versions.toml | 4 +- .../treenav/compose/threepane/ThreePane.kt | 15 ------- .../ThreePaneSharedTransitionScope.kt | 35 ++-------------- .../MovableSharedElementTransform.kt | 41 +------------------ .../treenav/compose/MultiPaneDisplay.kt | 35 ++++++++++++++-- .../treenav/compose/MultiPaneDisplayState.kt | 30 +++++++------- .../com/tunjid/treenav/compose/PaneEntry.kt | 1 - .../navigation3/ui/NavigationEventHandler.kt | 13 +++++- .../com/tunjid/demo/common/ui/DemoApp.kt | 5 +-- .../com/tunjid/demo/common/ui/DragToPop.kt | 19 --------- .../com/tunjid/demo/common/ui/PaneScaffold.kt | 29 +------------ .../tunjid/demo/common/ui/PredictiveBack.kt | 2 +- 12 files changed, 69 insertions(+), 160 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6b188d..a4b3198 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] androidGradlePlugin = "8.9.2" androidxActivity = "1.9.2" -activity-compose = "1.12.0-alpha01" +activity-compose = "1.12.0-alpha02" androidxAppCompat = "1.7.0" androidxBenchmark = "1.3.4" androidxCore = "1.16.0" androidxCollection = "1.5.0" androidxCompose = "1.7.0" -androidxNavigationEvent = "1.0.0-alpha01" +androidxNavigationEvent = "1.0.0-alpha02" androidxPaging = "3.3.2" androidxSavedState = "1.3.0-alpha07" androidxTestCore = "1.6.1" diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index f4db06d..1f930a9 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -40,13 +40,6 @@ enum class ThreePane { */ Primary, - /** - * A optional pane for placing content from the [Primary] pane, if a preview of the previous - * navigation destinations is occurring. The primary content is rendered here, while - * the previous primary content is rendered in the [Primary] pane. - */ - TransientPrimary, - /** * An optional pane for displaying a navigation destination alongside the [Primary] pane. * This is useful for list-detail, or supporting panels flows. @@ -75,11 +68,6 @@ enum class ThreePane { from = Secondary, to = Primary ) - - val PrimaryToTransient = Swap( - from = Primary, - to = TransientPrimary - ) } } @@ -104,9 +92,6 @@ fun threePaneEntry( render: @Composable (PaneScope.(R) -> Unit), ) = PaneEntry( paneTransform = paneMapping, - renderTransform = { destination, original -> - original(destination) - }, content = render ) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt index bf3bc52..ca58702 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt @@ -79,34 +79,9 @@ private class ThreePaneSharedTransitionScope @OptIn( "Shared elements may only be used in non null panes" ) // Allow shared elements in the primary or transient primary content only - ThreePane.Primary -> when { - paneScope.isPreviewingBack -> sharedElementWithCallerManagedVisibility( - sharedContentState = rememberSharedContentState(key), - visible = false, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - ) - // Share the element - else -> sharedElementWithCallerManagedVisibility( - sharedContentState = rememberSharedContentState(key), - visible = when (visible) { - null -> paneScope.isActive - else -> paneScope.isActive && visible - }, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - ) - } - // Share the element when in the transient pane - ThreePane.TransientPrimary -> sharedElementWithCallerManagedVisibility( + ThreePane.Primary -> sharedElement( sharedContentState = rememberSharedContentState(key), - visible = paneScope.isActive, + animatedVisibilityScope = paneScope, boundsTransform = boundsTransform, placeHolderSize = placeHolderSize, renderInOverlayDuringTransition = renderInOverlayDuringTransition, @@ -121,8 +96,4 @@ private class ThreePaneSharedTransitionScope @OptIn( -> this } } -} - -private val PaneScope.isPreviewingBack: Boolean - get() = paneState.pane == ThreePane.Primary - && paneState.adaptations.contains(ThreePane.PrimaryToTransient) \ No newline at end of file +} \ No newline at end of file diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index e4c17d4..ad01eac 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -23,7 +23,6 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope.OverlayClip import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize import androidx.compose.animation.core.Transition -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember @@ -46,16 +45,7 @@ import com.tunjid.treenav.compose.transforms.Transform * [ThreePane] layouts. * * It is an opinionated implementation that always shows the movable shared element in - * the [ThreePane.Primary] pane unless: - * - * - The [ThreePane.PrimaryToTransient] adaptation is present and a shared element match is - * found. During this, the movable shared element will be shown in - * the [ThreePane.TransientPrimary] pane. During this, an empty box will be rendered - * in the [ThreePane.Primary] pane. - * - * - The [ThreePane.PrimaryToTransient] adaptation is present and a shared element match is NOT - * found. During this, the element will simply be rendered as is in [ThreePane.Primary], but - * without movable content semantics. + * the [ThreePane.Primary] pane. * * Note: The movable shared element is never rendered in the following panes: * - [ThreePane.Secondary] @@ -143,25 +133,7 @@ private class ThreePaneMovableSharedElementScope( "Shared elements may only be used in non null panes" ) // Allow shared elements in the primary or transient primary content only - ThreePane.Primary -> when { - // Show a blank space for shared elements between the destinations - isPreviewingBack && hostState.isCurrentlyShared(key) -> EmptyElement - // If previewing and it won't be shared, show the item as is - isPreviewingBack -> sharedElement - // Share the element - else -> delegate.movableSharedElementOf( - key = key, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - alternateOutgoingSharedElement = alternateOutgoingSharedElement, - sharedElement = sharedElement - ) - } - // Share the element when in the transient pane - ThreePane.TransientPrimary -> delegate.movableSharedElementOf( + ThreePane.Primary -> delegate.movableSharedElementOf( key = key, boundsTransform = boundsTransform, placeHolderSize = placeHolderSize, @@ -179,12 +151,3 @@ private class ThreePaneMovableSharedElementScope( -> alternateOutgoingSharedElement ?: sharedElement } } - -private val PaneScope.isPreviewingBack: Boolean - get() = paneState.pane == ThreePane.Primary - && paneState.adaptations.contains(ThreePane.PrimaryToTransient) - -// An empty element representing blank space -private val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, modifier -> - Box(modifier) -} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 4fd8f88..3fa617d 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -43,9 +43,12 @@ import com.tunjid.treenav.compose.navigation3.runtime.NavEntry import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator import com.tunjid.treenav.compose.navigation3.ui.LocalNavAnimatedContentScope import com.tunjid.treenav.compose.navigation3.ui.NavDisplay +import com.tunjid.treenav.compose.navigation3.ui.NavigationEventHandler import com.tunjid.treenav.compose.navigation3.ui.Scene import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.collect /** * Scope that provides context about individual panes [Pane] in an [MultiPaneDisplay]. @@ -133,11 +136,13 @@ fun MultiPaneDisplay( slots = slots ) + val sceneStrategy = remember { MultiPanePaneSceneStrategy( state = state, slots = slots, currentPanedNavigationState = panedNavigationState::value, + isPreviewingBack = state.backPreviewState::value, content = content, ) } @@ -165,7 +170,8 @@ fun MultiPaneDisplay( NavEntry( key = key, metadata = mapOf( - Keys.ID_KEY to key.id + Keys.ID_KEY to key.id, + Keys.DESTINATION_KEY to key ), content = { destination -> val scope = LocalPaneScope.current @@ -175,6 +181,19 @@ fun MultiPaneDisplay( ) }, ) + + NavigationEventHandler( + enabled = { true }, + passThrough = true, + ) { progress -> + try { + state.backPreviewState.value = true + progress.collect() + state.backPreviewState.value = false + } catch (e: CancellationException) { + state.backPreviewState.value = false + } + } } @@ -182,6 +201,7 @@ fun MultiPaneDisplay( private class MultiPanePaneSceneStrategy( private val state: MultiPaneDisplayState, private val slots: Set, + private val isPreviewingBack: () -> Boolean, private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, private val content: @Composable (MultiPaneDisplayScope.() -> Unit), ) : SceneStrategy { @@ -218,6 +238,7 @@ private class MultiPanePaneSceneStrategy( private val backstackIds: List, private val destination: Destination, private val slots: Set, + private val isPreviewingBack: () -> Boolean, private val panesToDestinations: @Composable (Destination) -> Map, private val currentPanedNavigationState: SlotBasedPanedNavigationState, private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), @@ -266,7 +288,11 @@ private class MultiPaneDisplayScene( AnimatedPaneScope( paneState = paneState, activeState = derivedStateOf { - animatedContentScope.transition.targetState == EnterExitState.Visible + val previewing = isPreviewingBack() + val isEntering = + animatedContentScope.transition.targetState == EnterExitState.Visible + if (previewing) !isEntering + else isEntering }, animatedContentScope = animatedContentScope, ) @@ -321,7 +347,7 @@ internal fun SlotBasedPanedNavigationState> { +internal val LocalPaneScope = staticCompositionLocalOf> { throw IllegalArgumentException( "PaneScope should not be read until provided in the composition" ) @@ -329,6 +355,9 @@ private val LocalPaneScope = staticCompositionLocalOf> { internal object Keys { val ID_KEY = "com.tunjid.treenav.compose.id" + val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" val NavEntry<*>.id get() = metadata[ID_KEY] as String + inline fun NavEntry<*>.destination() = metadata[DESTINATION_KEY] as T + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index d0146ae..67ad75b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -16,8 +16,11 @@ package com.tunjid.treenav.compose +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import com.tunjid.treenav.Node import com.tunjid.treenav.compose.transforms.CompoundTransform import com.tunjid.treenav.compose.transforms.DestinationTransform @@ -42,15 +45,17 @@ import com.tunjid.treenav.compose.transforms.Transform * @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 popTransform: (NavigationState) -> NavigationState, - val onPopped: (NavigationState) -> Unit, - val panesToDestinationsTransform: @Composable (Destination) -> Map, - val renderTransform: @Composable PaneScope.(Destination) -> Unit, -) + internal val panes: List, + internal val navigationState: State, + internal val backStackTransform: (NavigationState) -> List, + internal val destinationTransform: (NavigationState) -> Destination, + internal val popTransform: (NavigationState) -> NavigationState, + internal val onPopped: (NavigationState) -> Unit, + internal val panesToDestinationsTransform: @Composable (Destination) -> Map, + internal val renderTransform: @Composable PaneScope.(Destination) -> Unit, +) { + internal val backPreviewState = mutableStateOf(false) +} /** * Provides an [MultiPaneDisplayState] for configuring a [MultiPaneDisplay] for @@ -92,12 +97,7 @@ fun MultiPaneDisplayState( }, renderTransform = { destination -> val nav = entryProvider(destination) - with(nav.renderTransform) { - Render( - destination = destination, - previousTransform = nav.content, - ) - } + nav.content(this, destination) } ), operation = MultiPaneDisplayState::plus 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 index 7923ecf..5a28461 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt @@ -11,7 +11,6 @@ import com.tunjid.treenav.compose.transforms.RenderTransform */ @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/navigation3/ui/NavigationEventHandler.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt index 5c39895..b2babee 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.launch @Composable internal fun NavigationEventHandler( enabled: () -> Boolean = { true }, + passThrough: Boolean = false, onBack: suspend (progress: Flow) -> Unit, ) { // ensure we don't re-register callbacks when onBack changes @@ -46,7 +47,12 @@ internal fun NavigationEventHandler( val navEventScope = rememberCoroutineScope() val navEventCallBack = remember { - NavigationEventHandlerCallback(enabled, navEventScope, currentOnBack) + NavigationEventHandlerCallback( + enabled = enabled, + passThrough = passThrough, + onBackScope = navEventScope, + currentOnBack = currentOnBack, + ) } // we want to use the same callback, but ensure we adjust the variable on recomposition @@ -100,9 +106,14 @@ private class OnBackInstance( private class NavigationEventHandlerCallback( enabled: () -> Boolean, + passThrough: Boolean, var onBackScope: CoroutineScope, var currentOnBack: suspend (progress: Flow) -> Unit, ) : NavigationEventCallback(enabled()) { + + init { + this.isPassThrough = passThrough + } private var onBackInstance: OnBackInstance? = null private var isActive = false 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 79f4119..9416793 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 @@ -160,10 +160,7 @@ fun App( ) }, itemContent = { index -> - DragToPopLayout( - state = appState, - pane = appState.filteredPaneOrder[index] - ) + Destination(appState.filteredPaneOrder[index]) } ) } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt index a63ce01..430ce12 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt @@ -64,25 +64,6 @@ fun Modifier.dragToPop(): Modifier { return offset { dragToDismissOffset } } -@Composable -internal fun MultiPaneDisplayScope.DragToPopLayout( - state: AppState, - pane: ThreePane, -) { - // Only place the DragToDismiss Modifier on the Primary pane - if (pane == ThreePane.Primary) { - Box( - modifier = Modifier.dragToPopInternal(state) - ) { - Destination(pane) - } - // Place the transient primary screen above the primary - Destination(ThreePane.TransientPrimary) - } else { - Destination(pane) - } -} - @Composable private fun Modifier.dragToPopInternal(state: AppState): Modifier { val density = LocalDensity.current diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt index f9dfc7a..f56750b 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt @@ -41,14 +41,12 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.roundToIntSize import androidx.compose.ui.zIndex -import com.tunjid.composables.ui.skipIf import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.treenav.compose.PaneScope import com.tunjid.treenav.compose.threepane.ThreePane @@ -56,7 +54,6 @@ import com.tunjid.treenav.compose.threepane.ThreePaneMovableElementSharedTransit import com.tunjid.treenav.compose.threepane.rememberThreePaneMovableElementSharedTransitionScope import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull -import kotlin.math.abs @Stable class PaneScaffoldState internal constructor( @@ -71,34 +68,13 @@ class PaneScaffoldState internal constructor( && appState.isMediumScreenWidthOrWider val canUseMovableNavigationBar - get() = canShowNavigationBar && when { - isActive && isPreviewingBack && paneState.pane == ThreePane.TransientPrimary -> true - isActive && !isPreviewingBack && paneState.pane == ThreePane.Primary -> true - else -> false - } + get() = canShowNavigationBar && isActive && paneState.pane == ThreePane.Primary val canUseMovableNavigationRail get() = canShowNavigationRail && isActive - internal val canShowFab - get() = when (paneState.pane) { - ThreePane.Primary -> true - ThreePane.TransientPrimary -> true - ThreePane.Secondary -> false - ThreePane.Tertiary -> false - ThreePane.Overlay -> false - null -> false - } - internal var scaffoldTargetSize by mutableStateOf(IntSize.Zero) internal var scaffoldCurrentSize by mutableStateOf(IntSize.Zero) - - internal fun hasMatchedSize(): Boolean = - abs(scaffoldCurrentSize.width - scaffoldTargetSize.width) <= 2 - && abs(scaffoldCurrentSize.height - scaffoldTargetSize.height) <= 2 - - private val isPreviewingBack: Boolean - get() = paneState.adaptations.contains(ThreePane.PrimaryToTransient) } @Composable @@ -236,9 +212,6 @@ private fun scaffoldBoundsTransform( -> if (canAnimatePane()) spring() else snap() - ThreePane.TransientPrimary, - -> spring().skipIf(paneScaffoldState::hasMatchedSize) - ThreePane.Overlay, null, -> snap() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt index 539046a..f836305 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt @@ -39,7 +39,7 @@ import com.tunjid.treenav.compose.threepane.ThreePane fun Modifier.predictiveBackBackgroundModifier( paneScope: PaneScope, ): Modifier { - if (paneScope.paneState.pane != ThreePane.TransientPrimary) +// if (paneScope.paneState.pane != ThreePane.TransientPrimary) return this var elevation by remember { mutableStateOf(0.dp) } From b72ca5f51a17a08e18db8bac692f564699b0f03c Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 14:11:44 -0400 Subject: [PATCH 16/78] Reallow duplicates in MultipaneDisplay backstack --- .../com/tunjid/treenav/compose/MultiPaneDisplay.kt | 1 + .../kotlin/com/tunjid/treenav/compose/StackNavExt.kt | 4 ++-- .../com/tunjid/treenav/compose/StackNavExtTest.kt | 11 ++--------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 3fa617d..12cf248 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -169,6 +169,7 @@ fun MultiPaneDisplay( entryProvider = { key -> NavEntry( key = key, + contentKey = key.id, metadata = mapOf( Keys.ID_KEY to key.id, Keys.DESTINATION_KEY to key diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt index 89b29af..d415b5c 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt @@ -29,7 +29,7 @@ inline fun MultiStackNav.multiPaneDisplayBackstack( backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ) .filterIsInstance() @@ -41,6 +41,6 @@ inline fun StackNav.multiPaneDisplayBackstack(): Li backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ) .filterIsInstance() \ No newline at end of file diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt index 1e9841f..520736c 100644 --- a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt @@ -40,18 +40,11 @@ class StackNavExtTest { .push(TestNode("E", children = listOf(TestNode("1"), TestNode("2")))) .push(TestNode("F")) - println( - pushed.backStack( - includeCurrentDestinationChildren = true, - placeChildrenBeforeParent = true, - distinctDestinations = true, - ) - ) assertEquals( expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ), actual = pushed.multiPaneDisplayBackstack() ) @@ -79,7 +72,7 @@ class StackNavExtTest { expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ), actual = pushed.multiPaneDisplayBackstack() ) From 0961952a9f1a9a8a9be1004f2c465c164db92167 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 15:24:35 -0400 Subject: [PATCH 17/78] Reallow duplicates in MultipaneDisplay backstack II --- .../com/tunjid/treenav/compose/MultiPaneDisplay.kt | 13 +++++++------ .../com/tunjid/treenav/compose/StackNavExt.kt | 4 ++-- .../com/tunjid/treenav/compose/StackNavExtTest.kt | 11 +++++++++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 12cf248..52007d8 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -169,7 +169,6 @@ fun MultiPaneDisplay( entryProvider = { key -> NavEntry( key = key, - contentKey = key.id, metadata = mapOf( Keys.ID_KEY to key.id, Keys.DESTINATION_KEY to key @@ -230,10 +229,9 @@ private class MultiPanePaneSceneStrategy + val index = mutableEntries.indexOfFirst { it.id == id } + mutableEntries.removeAt(index) + }, scopeContent = content ) } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt index d415b5c..89b29af 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt @@ -29,7 +29,7 @@ inline fun MultiStackNav.multiPaneDisplayBackstack( backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = false, + distinctDestinations = true, ) .filterIsInstance() @@ -41,6 +41,6 @@ inline fun StackNav.multiPaneDisplayBackstack(): Li backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = false, + distinctDestinations = true, ) .filterIsInstance() \ No newline at end of file diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt index 520736c..1e9841f 100644 --- a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt @@ -40,11 +40,18 @@ class StackNavExtTest { .push(TestNode("E", children = listOf(TestNode("1"), TestNode("2")))) .push(TestNode("F")) + println( + pushed.backStack( + includeCurrentDestinationChildren = true, + placeChildrenBeforeParent = true, + distinctDestinations = true, + ) + ) assertEquals( expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = false, + distinctDestinations = true, ), actual = pushed.multiPaneDisplayBackstack() ) @@ -72,7 +79,7 @@ class StackNavExtTest { expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = false, + distinctDestinations = true, ), actual = pushed.multiPaneDisplayBackstack() ) From 001ff0908ae86eb68f085658f3da7161303ef5b6 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 15:37:50 -0400 Subject: [PATCH 18/78] Delete dangling class --- .../lifecycle/DestinationLifecycleOwner.kt | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationLifecycleOwner.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationLifecycleOwner.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationLifecycleOwner.kt deleted file mode 100644 index 421073c..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationLifecycleOwner.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tunjid.treenav.compose.lifecycle - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.State -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.SlotBasedPanedNavigationState - -@Composable -internal fun rememberDestinationLifecycleOwner( - destination: Node, -): DestinationLifecycleOwner { - val hostLifecycleOwner = LocalLifecycleOwner.current - val destinationLifecycleOwner = remember(hostLifecycleOwner) { - DestinationLifecycleOwner( - destination = destination, - host = hostLifecycleOwner - ) - } - return destinationLifecycleOwner -} - -@Stable -internal class DestinationLifecycleOwner( - private val destination: Node, - private val host: LifecycleOwner -) : LifecycleOwner { - - private val lifecycleRegistry = LifecycleRegistry(this) - - override val lifecycle: Lifecycle - get() = lifecycleRegistry - - val hostLifecycleState = host.lifecycle - - fun update( - hostLifecycleState: State, - paneScope: PaneScope<*, *>, - panedNavigationState: SlotBasedPanedNavigationState<*, *>, - ) { - val active = paneScope.isActive - val exists = panedNavigationState.backStackIds.contains( - destination.id - ) - val derivedLifecycleState = when { - !exists -> State.DESTROYED - !active -> State.STARTED - else -> hostLifecycleState - } - lifecycleRegistry.currentState = - if (host.lifecycle.currentState.ordinal < derivedLifecycleState.ordinal) hostLifecycleState - else derivedLifecycleState - } -} \ No newline at end of file From 05ffc97e88cc47cf94b7d18401c1456e98362224 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 15:45:31 -0400 Subject: [PATCH 19/78] Add contentKey to NavDisplay and allow duplicates in backstack --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 1 + .../commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt | 2 +- .../kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 52007d8..dd77321 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -169,6 +169,7 @@ fun MultiPaneDisplay( entryProvider = { key -> NavEntry( key = key, + contentKey = key.id, metadata = mapOf( Keys.ID_KEY to key.id, Keys.DESTINATION_KEY to key diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt index 89b29af..87727b8 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt @@ -41,6 +41,6 @@ inline fun StackNav.multiPaneDisplayBackstack(): Li backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ) .filterIsInstance() \ No newline at end of file diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt index 1e9841f..9f56bdc 100644 --- a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt @@ -79,7 +79,7 @@ class StackNavExtTest { expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ), actual = pushed.multiPaneDisplayBackstack() ) From bf6a1a3690412917c9fe33547bfe19d374a87c81 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 15:57:42 -0400 Subject: [PATCH 20/78] Better matching for popping backstack --- .../treenav/compose/MultiPaneDisplay.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index dd77321..2cbc744 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Keys.children import com.tunjid.treenav.compose.Keys.id import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator import com.tunjid.treenav.compose.navigation3.runtime.NavEntry @@ -172,7 +173,8 @@ fun MultiPaneDisplay( contentKey = key.id, metadata = mapOf( Keys.ID_KEY to key.id, - Keys.DESTINATION_KEY to key + Keys.DESTINATION_KEY to key, + Keys.CHILDREN_KEY to key.children, ), content = { destination -> val scope = LocalPaneScope.current @@ -229,8 +231,7 @@ private class MultiPanePaneSceneStrategy - val index = mutableEntries.indexOfFirst { it.id == id } + // Try to match up NavEntries to state using their id and children. + // Best case is O(n) where the backstack isn't shuffled. + previousEntries = poppedBackstack.map { destination -> + val index = mutableEntries.indexOfFirst { + it.id == destination.id && it.children == destination.children + } mutableEntries.removeAt(index) }, scopeContent = content @@ -359,8 +364,11 @@ internal val LocalPaneScope = staticCompositionLocalOf> { internal object Keys { val ID_KEY = "com.tunjid.treenav.compose.id" val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" + val CHILDREN_KEY = "com.tunjid.treenav.compose.children" + + internal val NavEntry<*>.id get() = metadata[ID_KEY] as String + internal val NavEntry<*>.children get() = metadata[CHILDREN_KEY] - val NavEntry<*>.id get() = metadata[ID_KEY] as String - inline fun NavEntry<*>.destination() = metadata[DESTINATION_KEY] as T + internal inline fun NavEntry<*>.destination() = metadata[DESTINATION_KEY] as T } \ No newline at end of file From afdd8df0287807bb7a2a6d39f87354c1cccfdfb5 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 16:14:03 -0400 Subject: [PATCH 21/78] Acces modifiers for constants --- .../com/tunjid/treenav/compose/MultiPaneDisplay.kt | 9 +++++---- .../kotlin/com/tunjid/treenav/compose/StackNavExt.kt | 2 +- .../kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt | 9 +-------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 2cbc744..a84cf44 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -362,13 +362,14 @@ internal val LocalPaneScope = staticCompositionLocalOf> { } internal object Keys { - val ID_KEY = "com.tunjid.treenav.compose.id" - val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" - val CHILDREN_KEY = "com.tunjid.treenav.compose.children" + internal const val ID_KEY = "com.tunjid.treenav.compose.id" + internal const val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" + internal const val CHILDREN_KEY = "com.tunjid.treenav.compose.children" internal val NavEntry<*>.id get() = metadata[ID_KEY] as String internal val NavEntry<*>.children get() = metadata[CHILDREN_KEY] - internal inline fun NavEntry<*>.destination() = metadata[DESTINATION_KEY] as T + internal inline fun NavEntry<*>.destination() = + metadata[DESTINATION_KEY] as T } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt index 87727b8..d415b5c 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt @@ -29,7 +29,7 @@ inline fun MultiStackNav.multiPaneDisplayBackstack( backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ) .filterIsInstance() diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt index 9f56bdc..520736c 100644 --- a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt @@ -40,18 +40,11 @@ class StackNavExtTest { .push(TestNode("E", children = listOf(TestNode("1"), TestNode("2")))) .push(TestNode("F")) - println( - pushed.backStack( - includeCurrentDestinationChildren = true, - placeChildrenBeforeParent = true, - distinctDestinations = true, - ) - ) assertEquals( expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ), actual = pushed.multiPaneDisplayBackstack() ) From 6825a4cd839e3f0e9f1994bcc04f21e68a295556 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 16:24:58 -0400 Subject: [PATCH 22/78] Pass PaneScaffoldState to screens --- .../demo/common/ui/avatar/AvatarScreen.kt | 6 +++--- .../tunjid/demo/common/ui/avatar/PaneEntry.kt | 2 +- .../common/ui/chatrooms/ChatRoomsScreen.kt | 18 +++++++++--------- .../demo/common/ui/chatrooms/PaneEntry.kt | 2 +- .../com/tunjid/demo/common/ui/me/PaneEntry.kt | 2 +- .../tunjid/demo/common/ui/profile/PaneEntry.kt | 2 +- .../demo/common/ui/profile/ProfileScreen.kt | 14 +++++++------- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt index 9a8bab2..dd9fc0c 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt @@ -25,16 +25,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.dragToPop -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun AvatarScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -46,7 +46,7 @@ fun AvatarScreen( .fillMaxSize() ) { val profileName = state.profileName ?: state.profile?.name ?: "" - movableSharedElementScope.updatedMovableSharedElementOf( + paneScaffoldState.updatedMovableSharedElementOf( key = "${state.roomName}-$profileName", state = ProfilePhotoArgs( profileName = profileName, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt index 9cf1556..d663eb9 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt @@ -55,7 +55,7 @@ fun avatarPaneEntry() = threePaneEntry( containerColor = Color.Transparent, content = { AvatarScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, modifier = Modifier.fillMaxSize() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt index 88b504e..ccc63bc 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt @@ -45,19 +45,19 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.tunjid.composables.collapsingheader.CollapsingHeaderLayout import com.tunjid.composables.collapsingheader.CollapsingHeaderState +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.SampleTopAppBar import com.tunjid.demo.common.ui.data.ChatRoom import com.tunjid.demo.common.ui.data.Message import com.tunjid.demo.common.ui.rememberAppBarCollapsingHeaderState -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf import kotlin.math.roundToInt @Composable fun ChatRoomsScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -72,7 +72,7 @@ fun ChatRoomsScreen( }, body = { ChatRooms( - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, state = state, onAction = onAction, ) @@ -102,7 +102,7 @@ private fun Header(headerState: CollapsingHeaderState) { @Composable private fun ChatRooms( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit ) { @@ -114,7 +114,7 @@ private fun ChatRooms( key = ChatRoom::name, itemContent = { room -> ChatRoomListItem( - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, roomName = room.name, participants = room.messages .map(Message::sender) @@ -131,7 +131,7 @@ private fun ChatRooms( @Composable fun ChatRoomListItem( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, roomName: String, participants: List, modifier: Modifier = Modifier, @@ -153,7 +153,7 @@ fun ChatRoomListItem( verticalAlignment = Alignment.CenterVertically ) { ChatRoomParticipants( - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, participants = participants, roomName = roomName, ) @@ -169,10 +169,10 @@ fun ChatRoomListItem( @OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class) @Composable fun ChatRoomParticipants( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, participants: List, roomName: String, -) = with(movableSharedElementScope) { +) = with(paneScaffoldState) { FlowRow( modifier = Modifier .width(64.dp) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt index 1eabeba..c426e9b 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt @@ -46,7 +46,7 @@ fun chatRoomPaneEntry( .fillMaxSize(), content = { ChatRoomsScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt index 07bf1c2..8278117 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt @@ -48,7 +48,7 @@ fun mePaneEntry( .fillMaxSize(), content = { ProfileScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt index 8a1bd38..b1c61b7 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt @@ -57,7 +57,7 @@ fun profilePaneEntry() = threePaneEntry( .fillMaxSize(), content = { ProfileScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, modifier = Modifier.fillMaxSize() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt index 55830cb..176390a 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt @@ -38,17 +38,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.tunjid.composables.collapsingheader.CollapsingHeaderLayout +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.SampleTopAppBar import com.tunjid.demo.common.ui.rememberAppBarCollapsingHeaderState -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf import kotlin.math.roundToInt @Composable fun ProfileScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -61,7 +61,7 @@ fun ProfileScreen( headerContent = { ProfileHeader( state = state, - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, onBackPressed = remember(state.profileName) { if (state.profileName != null) return@remember { onAction(Action.Navigation.Pop) @@ -92,7 +92,7 @@ fun ProfileScreen( @Composable private fun ProfileHeader( state: State, - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, modifier: Modifier = Modifier, onBackPressed: (() -> Unit)?, ) { @@ -101,7 +101,7 @@ private fun ProfileHeader( ) { ProfilePhoto( state = state, - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, modifier = modifier ) SampleTopAppBar( @@ -115,12 +115,12 @@ private fun ProfileHeader( @Composable private fun ProfilePhoto( state: State, - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, modifier: Modifier = Modifier, ) { val profileName = state.profileName ?: state.profile?.name if (profileName != null) { - movableSharedElementScope.updatedMovableSharedElementOf( + paneScaffoldState.updatedMovableSharedElementOf( key = "${state.roomName}-$profileName", state = ProfilePhotoArgs( profileName = profileName, From 2ec80c5b1998fc5f6a880a1a50d25c15cf3d5832 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 16:28:39 -0400 Subject: [PATCH 23/78] Pass PaneScaffoldState to screens II --- .../com/tunjid/demo/common/ui/chat/ChatScreen.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt index aae0221..b3925f6 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt @@ -53,7 +53,6 @@ import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.SampleTopAppBar import com.tunjid.demo.common.ui.data.Message import com.tunjid.demo.common.ui.data.Profile -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf import com.tunjid.treenav.compose.threepane.ThreePane import kotlinx.datetime.Instant @@ -88,7 +87,7 @@ fun ChatScreen( navigateToProfile = onAction, modifier = Modifier.fillMaxSize(), scrollState = scrollState, - movableSharedElementScope = paneScaffoldState, + paneScaffoldState = paneScaffoldState, ) } } @@ -103,7 +102,7 @@ fun Messages( navigateToProfile: (Action.Navigation.GoToProfile) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier, - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, ) { Box(modifier = modifier) { LazyColumn( @@ -128,7 +127,7 @@ fun Messages( isInPrimaryPane = isInPrimaryPane, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, ) } } @@ -145,7 +144,7 @@ fun Message( isInPrimaryPane: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, - movableSharedElementScope: MovableSharedElementScope + paneScaffoldState: PaneScaffoldState, ) { val borderColor = if (isUserMe) { MaterialTheme.colorScheme.primary @@ -178,7 +177,7 @@ fun Message( } }, ) { - movableSharedElementScope.updatedMovableSharedElementOf( + paneScaffoldState.updatedMovableSharedElementOf( key = "$roomName-${item.sender.name}", state = ProfilePhotoArgs( profileName = item.sender.name, From 849cef2eec3057e32f1eef36fa5e68a186d9c2e6 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 21 Jun 2025 19:00:54 -0400 Subject: [PATCH 24/78] Got rid of unnecessary destination transform --- .../treenav/compose/MultiPaneDisplayState.kt | 39 ++++----------- .../treenav/compose/transforms/Transforms.kt | 50 ------------------- 2 files changed, 11 insertions(+), 78 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 67ad75b..cb11068 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -16,14 +16,10 @@ package com.tunjid.treenav.compose -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf 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 @@ -45,14 +41,14 @@ import com.tunjid.treenav.compose.transforms.Transform * @param renderTransform the transform used to render a [Destination] in its pane. */ class MultiPaneDisplayState internal constructor( - internal val panes: List, - internal val navigationState: State, - internal val backStackTransform: (NavigationState) -> List, - internal val destinationTransform: (NavigationState) -> Destination, - internal val popTransform: (NavigationState) -> NavigationState, - internal val onPopped: (NavigationState) -> Unit, - internal val panesToDestinationsTransform: @Composable (Destination) -> Map, - internal val renderTransform: @Composable PaneScope.(Destination) -> Unit, + internal val panes: List, + internal val navigationState: State, + internal val backStackTransform: (NavigationState) -> List, + internal val destinationTransform: (NavigationState) -> Destination, + internal val popTransform: (NavigationState) -> NavigationState, + internal val onPopped: (NavigationState) -> Unit, + internal val panesToDestinationsTransform: @Composable (Destination) -> Map, + internal val renderTransform: @Composable PaneScope.(Destination) -> Unit, ) { internal val backPreviewState = mutableStateOf(false) } @@ -107,26 +103,13 @@ private operator fun MultiPaneDisplayState.plus( transform: Transform, ): MultiPaneDisplayState = - if (transform is CompoundTransform) transform.transforms.fold( - initial = this, - operation = MultiPaneDisplayState::plus, - ) - else MultiPaneDisplayState( + MultiPaneDisplayState( panes = panes, navigationState = navigationState, backStackTransform = backStackTransform, popTransform = popTransform, - onPopped = onPopped, - destinationTransform = when (transform) { - is DestinationTransform -> { destination -> - transform.toDestination( - navigationState = destination, - previousTransform = destinationTransform - ) - } - - else -> destinationTransform - }, + onPopped = onPopped, + destinationTransform = destinationTransform, panesToDestinationsTransform = when (transform) { is PaneTransform -> { destination -> transform.toPanesAndDestinations( 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 index a79ccc7..d8dc979 100644 --- 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 @@ -11,27 +11,6 @@ import com.tunjid.treenav.compose.PaneScope */ 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]. */ @@ -75,32 +54,3 @@ fun interface RenderTransform 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 From 7fcc16b710b357302d7a997f358f1e44fb5ade42 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 09:12:17 -0400 Subject: [PATCH 25/78] Bump CMP version --- gradle/libs.versions.toml | 6 ++--- .../common/ui/chatrooms/ChatRoomsScreen.kt | 23 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4b3198..ea5b497 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,15 +15,15 @@ androidxTestExt = "1.2.1" androidxTestRunner = "1.6.2" androidxTestRules = "1.6.1" dokka = "1.8.20" -jetbrainsCompose = "1.8.0" -jetbrainsLifecycle = "2.9.0-beta01" +jetbrainsCompose = "1.8.2" +jetbrainsLifecycle = "2.9.1" jetbrainsMaterial3Adaptive = "1.0.1" junit4 = "4.13.2" kotlin = "2.1.20" kotlinxCoroutines = "1.10.2" kotlinxDatetime = "0.6.2" tunjidStateHolder = "1.1.0" -tunjidComposables = "0.0.16" +tunjidComposables = "0.0.19" junit = "4.13.2" runner = "1.0.2" espressoCore = "3.0.2" diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt index ccc63bc..f98996a 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt @@ -82,17 +82,18 @@ fun ChatRoomsScreen( @Composable private fun Header(headerState: CollapsingHeaderState) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .offset { - IntOffset( - x = 0, - y = -headerState.translation.roundToInt() - ) - } - ) { + Box{ + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .offset { + IntOffset( + x = 0, + y = -headerState.translation.roundToInt() + ) + } + ) SampleTopAppBar( title = "Chat Rooms", onBackPressed = null From 65bb1d3f6983a9233835be7268c35db0fc7cd51a Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 11:45:26 -0400 Subject: [PATCH 26/78] Update arguments of paneSharedElement --- .../compose/threepane/ThreePaneSharedTransitionScope.kt | 4 ++-- .../com/tunjid/treenav/compose/PaneSharedTransitionScope.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt index ca58702..1b3b087 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt @@ -65,7 +65,7 @@ private class ThreePaneSharedTransitionScope @OptIn( @OptIn(ExperimentalSharedTransitionApi::class) override fun Modifier.paneSharedElement( - key: Any, + sharedContentState: SharedTransitionScope.SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, @@ -80,7 +80,7 @@ private class ThreePaneSharedTransitionScope @OptIn( ) // Allow shared elements in the primary or transient primary content only ThreePane.Primary -> sharedElement( - sharedContentState = rememberSharedContentState(key), + sharedContentState = sharedContentState, animatedVisibilityScope = paneScope, boundsTransform = boundsTransform, placeHolderSize = placeHolderSize, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt index d85256e..3e5a8ab 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt @@ -46,7 +46,7 @@ interface PaneSharedTransitionScope : * @see [SharedTransitionScope.sharedElement]. */ fun Modifier.paneSharedElement( - key: Any, + sharedContentState: SharedTransitionScope.SharedContentState, boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, placeHolderSize: PlaceHolderSize = contentSize, renderInOverlayDuringTransition: Boolean = true, From d1b83bdc599979e85b046b6b82a324582f5c11bc Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 13:37:24 -0400 Subject: [PATCH 27/78] Fix crash in SavedStateNavEntryDecorator filed in https://issuetracker.google.com/issues/426971766 --- .../navigation3/runtime/SavedStateNavEntryDecorator.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt index ee7ec94..e79860b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt @@ -18,6 +18,7 @@ package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -95,7 +96,10 @@ internal fun SavedStateNavEntryDecorator( entry.Content() } } - childRegistry.lifecycle.currentState = Lifecycle.State.RESUMED + DisposableEffect(Unit) { + childRegistry.lifecycle.currentState = Lifecycle.State.RESUMED + onDispose { } + } } } From 453b003765233fa3c4f35abc1db1212daa36bfcb Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 14:41:55 -0400 Subject: [PATCH 28/78] Update signature of movableShared element API to take a SharedContentState --- .../MovableSharedElementTransform.kt | 4 ++-- .../MovableSharedElements.kt | 23 ++++++++----------- .../demo/common/ui/avatar/AvatarScreen.kt | 4 +++- .../tunjid/demo/common/ui/chat/ChatScreen.kt | 4 +++- .../common/ui/chatrooms/ChatRoomsScreen.kt | 6 +++-- .../demo/common/ui/profile/ProfileScreen.kt | 4 +++- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index ad01eac..2af259e 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -120,7 +120,7 @@ private class ThreePaneMovableSharedElementScope( @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( - key: Any, + sharedContentState: SharedTransitionScope.SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, @@ -134,7 +134,7 @@ private class ThreePaneMovableSharedElementScope( ) // Allow shared elements in the primary or transient primary content only ThreePane.Primary -> delegate.movableSharedElementOf( - key = key, + sharedContentState = sharedContentState, boundsTransform = boundsTransform, placeHolderSize = placeHolderSize, renderInOverlayDuringTransition = renderInOverlayDuringTransition, 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 f55184b..73578c4 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 @@ -41,7 +41,7 @@ interface MovableSharedElementScope { * NOTE: It is an error to compose the movable shared element in different locations * simultaneously, and the behavior of the shared element is undefined in this case. * - * @param key The shared element key to identify the movable shared element. + * @param sharedContentState The shared element key to identify the movable shared element. * @param boundsTransform Allows for customizing the animation for the bounds of * the [sharedElement]. * @param placeHolderSize Allows for adjusting the reported size to the parent layout during @@ -66,7 +66,7 @@ interface MovableSharedElementScope { */ @OptIn(ExperimentalSharedTransitionApi::class) fun movableSharedElementOf( - key: Any, + sharedContentState: SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, @@ -83,7 +83,7 @@ interface MovableSharedElementScope { * * @see [MovableSharedElementScope.movableSharedElementOf]. * - * @param key The shared element key to identify the movable shared element. + * @param sharedContentState The shared element key to identify the movable shared element. * @param boundsTransform Allows for customizing the animation for the bounds of * the [sharedElement]. * @param placeHolderSize Allows for adjusting the reported size to the parent layout during @@ -107,7 +107,7 @@ interface MovableSharedElementScope { @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun MovableSharedElementScope.updatedMovableSharedElementOf( - key: Any, + sharedContentState: SharedContentState, state: T, modifier: Modifier = Modifier, boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, @@ -118,7 +118,7 @@ fun MovableSharedElementScope.updatedMovableSharedElementOf( alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)? = null, sharedElement: @Composable (T, Modifier) -> Unit ) = movableSharedElementOf( - key = key, + sharedContentState = sharedContentState, boundsTransform = boundsTransform, placeHolderSize = placeHolderSize, renderInOverlayDuringTransition = renderInOverlayDuringTransition, @@ -164,15 +164,14 @@ class MovableSharedElementHostState( */ @Suppress("UnusedReceiverParameter") fun MovableSharedElementScope.createOrUpdateSharedElement( - key: Any, sharedContentState: SharedContentState, sharedElement: @Composable (S, Modifier) -> Unit, ): @Composable (S, Modifier) -> Unit { - val movableSharedElementState = keysToMovableSharedElements.getOrPut(key) { + val movableSharedElementState = keysToMovableSharedElements.getOrPut(sharedContentState.key) { MovableSharedElementState( sharedContentState = sharedContentState, sharedElement = sharedElement, - onRemoved = { keysToMovableSharedElements.remove(key) } + onRemoved = { keysToMovableSharedElements.remove(sharedContentState.key) } ) }.also { it.sharedContentState = sharedContentState } @@ -212,7 +211,7 @@ class PaneMovableSharedElementScope internal construct @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( - key: Any, + sharedContentState: SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, @@ -222,7 +221,6 @@ class PaneMovableSharedElementScope internal construct sharedElement: @Composable (T, Modifier) -> Unit ): @Composable (T, Modifier) -> Unit = { state, modifier -> with(movableSharedElementHostState) { - val sharedContentState = rememberSharedContentState(key) Box( modifier .sharedElementWithCallerManagedVisibility( @@ -238,7 +236,6 @@ class PaneMovableSharedElementScope internal construct when { paneScope.isActive -> createOrUpdateSharedElement( - key = key, sharedContentState = sharedContentState, sharedElement = sharedElement )(state, Modifier.fillMaxConstraints()) @@ -248,8 +245,8 @@ class PaneMovableSharedElementScope internal construct else -> when { // The element is being shared in its new destination, stop showing it // in the in active one - movableSharedElementHostState.isCurrentlyShared(key) - && movableSharedElementHostState.isMatchFound(key) -> Defaults.EmptyElement( + movableSharedElementHostState.isCurrentlyShared(sharedContentState.key) + && movableSharedElementHostState.isMatchFound(sharedContentState.key) -> Defaults.EmptyElement( state, Modifier.fillMaxConstraints() ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt index dd9fc0c..72e4415 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt @@ -47,7 +47,9 @@ fun AvatarScreen( ) { val profileName = state.profileName ?: state.profile?.name ?: "" paneScaffoldState.updatedMovableSharedElementOf( - key = "${state.roomName}-$profileName", + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "${state.roomName}-$profileName" + ), state = ProfilePhotoArgs( profileName = profileName, contentScale = ContentScale.Crop, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt index b3925f6..eac90a9 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt @@ -178,7 +178,9 @@ fun Message( }, ) { paneScaffoldState.updatedMovableSharedElementOf( - key = "$roomName-${item.sender.name}", + sharedContentState = paneScaffoldState.rememberSharedContentState( + key ="$roomName-${item.sender.name}" + ), state = ProfilePhotoArgs( profileName = item.sender.name, contentScale = ContentScale.Crop, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt index f98996a..b8cb210 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt @@ -82,7 +82,7 @@ fun ChatRoomsScreen( @Composable private fun Header(headerState: CollapsingHeaderState) { - Box{ + Box { Box( modifier = Modifier .fillMaxWidth() @@ -186,7 +186,9 @@ fun ChatRoomParticipants( ) { participants.forEach { profileName -> updatedMovableSharedElementOf( - key = "$roomName-${profileName}", + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "$roomName-${profileName}" + ), state = ProfilePhotoArgs( profileName = profileName, contentScale = ContentScale.Crop, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt index 176390a..8d50cfe 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt @@ -121,7 +121,9 @@ private fun ProfilePhoto( val profileName = state.profileName ?: state.profile?.name if (profileName != null) { paneScaffoldState.updatedMovableSharedElementOf( - key = "${state.roomName}-$profileName", + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "${state.roomName}-$profileName" + ), state = ProfilePhotoArgs( profileName = profileName, contentScale = ContentScale.Crop, From 9c91517fd5d24830af884449f8b25bb20e446a5b Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 15:26:07 -0400 Subject: [PATCH 29/78] Add isPreviewing to PaneScope --- .../MovableSharedElementTransform.kt | 3 +++ .../treenav/compose/MultiPaneDisplay.kt | 10 +------ .../com/tunjid/treenav/compose/PaneScope.kt | 26 +++++++++++++++---- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 2af259e..08cadf2 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -118,6 +118,9 @@ private class ThreePaneMovableSharedElementScope( override val isActive: Boolean get() = delegate.paneScope.isActive + override val inPredictiveBack: Boolean + get() = delegate.paneScope.inPredictiveBack + @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( sharedContentState: SharedTransitionScope.SharedContentState, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index a84cf44..3b1673b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -17,12 +17,10 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterExitState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -295,13 +293,7 @@ private class MultiPaneDisplayScene( val scope = remember { AnimatedPaneScope( paneState = paneState, - activeState = derivedStateOf { - val previewing = isPreviewingBack() - val isEntering = - animatedContentScope.transition.targetState == EnterExitState.Visible - if (previewing) !isEntering - else isEntering - }, + isPreviewingBack = isPreviewingBack, animatedContentScope = animatedContentScope, ) }.also { it.paneState = paneState } 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 576f3e1..908c67e 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 @@ -18,8 +18,9 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState import androidx.compose.runtime.Stable -import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -38,11 +39,19 @@ interface PaneScope : AnimatedVisibilityScope { val paneState: PaneState /** - * Whether or not this [PaneScope] is active in its current pane. It is inactive when - * it is animating out of its [AnimatedVisibilityScope]. + * Whether or not this [PaneScope] is active in its current pane. It is active when + * the current navigation destination is rendering this pane. + * + * This means that during predictive back animations, the outgoing panes, i.e the panes + * whose [AnimatedVisibilityScope.transition] report [EnterExitState.Visible] are considered + * active. */ val isActive: Boolean + /** + * Whether or not a predictive back gesture is in progress + */ + val inPredictiveBack: Boolean } /** @@ -51,13 +60,20 @@ interface PaneScope : AnimatedVisibilityScope { @Stable internal class AnimatedPaneScope( paneState: PaneState, - activeState: State, + val isPreviewingBack: () -> Boolean, val animatedContentScope: AnimatedContentScope ) : PaneScope, AnimatedVisibilityScope by animatedContentScope { override var paneState by mutableStateOf(paneState) - override val isActive: Boolean by activeState + override val isActive: Boolean by derivedStateOf { + val isEntering = animatedContentScope.transition.targetState == EnterExitState.Visible + if (inPredictiveBack) !isEntering + else isEntering + } + + override val inPredictiveBack: Boolean + get() = isPreviewingBack() } /** From fa5ac22b5e8da566afa04ec81876d8ad510e04dd Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 16:37:27 -0400 Subject: [PATCH 30/78] Make profileFor synchronous --- .../kotlin/com/tunjid/demo/common/ui/data/AppData.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt index 344abb7..5d9fb1e 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt @@ -63,9 +63,9 @@ object ProfileRepository { chatData.profiles.values.random() ) - fun profileFor(name: String): Flow = flow { - chatData.profiles[name]?.let { emit(it) } - } + fun profileFor(name: String): Flow = flowOf( + chatData.profiles.getValue(name) + ) } From 19baf7b7ae30197a14c8b039552153e9599bd485 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 18:53:12 -0400 Subject: [PATCH 31/78] Update shared elements for ChatScreen --- .../com/tunjid/demo/common/ui/AppBars.kt | 21 +++++-- .../tunjid/demo/common/ui/chat/ChatScreen.kt | 58 +++++++++++++++++-- .../demo/common/ui/chat/ChatViewModel.kt | 7 ++- .../common/ui/chatrooms/ChatRoomsScreen.kt | 16 +++-- .../common/ui/chatrooms/ChatRoomsViewModel.kt | 3 +- .../common/ui/data/NavigationRepository.kt | 1 + .../demo/common/ui/profile/ProfileScreen.kt | 2 +- 7 files changed, 90 insertions(+), 18 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt index ea490ce..1c5251d 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt @@ -53,14 +53,12 @@ fun rememberAppBarCollapsingHeaderState( @OptIn(ExperimentalMaterial3Api::class) @Composable fun SampleTopAppBar( - title: String, + title: @Composable () -> Unit, onBackPressed: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { TopAppBar( - title = { - Text(text = title) - }, + title = title, navigationIcon = { if (onBackPressed != null) IconButton( onClick = onBackPressed, @@ -75,4 +73,19 @@ fun SampleTopAppBar( colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), modifier = modifier, ) +} + +@Composable +fun SampleTopAppBar( + title: String, + onBackPressed: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + SampleTopAppBar( + title = { + Text(text = title) + }, + onBackPressed = onBackPressed, + modifier = modifier, + ) } \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt index eac90a9..b9b8cac 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFrom import androidx.compose.foundation.layout.size @@ -47,6 +48,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs @@ -72,7 +74,13 @@ fun ChatScreen( ) { val isInPrimaryPane = paneScaffoldState.paneState.pane == ThreePane.Primary SampleTopAppBar( - title = state.room?.name ?: "", + title = { + ChatTitle( + roomName = state.roomName, + participants = state.participants, + paneScaffoldState = paneScaffoldState + ) + }, onBackPressed = remember(isInPrimaryPane) { if (isInPrimaryPane) return@remember { onAction(Action.Navigation.Pop) @@ -82,16 +90,54 @@ fun ChatScreen( Messages( me = state.me, roomName = state.room?.name, - messages = state.chats, isInPrimaryPane = isInPrimaryPane, + messages = state.chats, navigateToProfile = onAction, - modifier = Modifier.fillMaxSize(), scrollState = scrollState, + modifier = Modifier.fillMaxSize(), paneScaffoldState = paneScaffoldState, ) } } +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun ChatTitle( + roomName: String, + participants: List, + paneScaffoldState: PaneScaffoldState +) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = roomName + ) + Spacer( + modifier = Modifier + .width(16.dp) + ) + participants.forEachIndexed { index, participant -> + paneScaffoldState.updatedMovableSharedElementOf( + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "${roomName}-${participant}" + ), + state = ProfilePhotoArgs( + profileName = participant, + contentScale = ContentScale.Crop, + cornerRadius = 42.dp, + contentDescription = null, + ), + modifier = Modifier + .size(24.dp) + .offset(x = index * (-8).dp), + sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> + ProfilePhoto(args, innerModifier) + } + ) + } + } +} @Composable fun Messages( @@ -121,13 +167,13 @@ fun Messages( Message( onAuthorClick = navigateToProfile, - roomName = roomName, item = content, + roomName = roomName, isUserMe = content.sender.name == me?.name, isInPrimaryPane = isInPrimaryPane, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, - paneScaffoldState = paneScaffoldState, + paneScaffoldState = paneScaffoldState ) } } @@ -179,7 +225,7 @@ fun Message( ) { paneScaffoldState.updatedMovableSharedElementOf( sharedContentState = paneScaffoldState.rememberSharedContentState( - key ="$roomName-${item.sender.name}" + key ="$roomName-${item.sender.name}-profile" ), state = ProfilePhotoArgs( profileName = item.sender.name, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt index 1c4184d..e05d1d5 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt @@ -50,7 +50,10 @@ class ChatViewModel( chat: SampleDestination.Chat, ) : ViewModel(coroutineScope), ActionStateMutator> by coroutineScope.actionStateFlowMutator( - initialState = State(), + initialState = State( + roomName = chat.roomName, + participants = chat.participants, + ), inputs = listOf( profileRepository.meMutations(), chatsRepository.chatRoomMutations(chat), @@ -106,7 +109,9 @@ private fun chatLoadMutations( data class State( val me: Profile? = null, + val roomName: String, val room: ChatRoom? = null, + val participants: List, val chats: List = emptyList(), ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt index b8cb210..1c218c3 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt @@ -114,15 +114,21 @@ private fun ChatRooms( items = state.chatRooms, key = ChatRoom::name, itemContent = { room -> + val participants = room.messages + .map(Message::sender) + .distinct() + .take(3) ChatRoomListItem( paneScaffoldState = paneScaffoldState, roomName = room.name, - participants = room.messages - .map(Message::sender) - .distinct() - .take(3), + participants = participants, onRoomClicked = { - onAction(Action.Navigation.ToRoom(roomName = it)) + onAction( + Action.Navigation.ToRoom( + roomName = it, + participants = participants, + ) + ) } ) } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt index 64301fb..db870bb 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt @@ -72,8 +72,9 @@ sealed class Action( sealed class Navigation : Action("Navigation"), NavigationAction { data class ToRoom( val roomName: String, + val participants: List, ) : Navigation(), NavigationAction by navigationAction( - { push(SampleDestination.Chat(roomName)) } + { push(SampleDestination.Chat(roomName, participants)) } ) } } \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt index a45bbb6..6d9d520 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt @@ -49,6 +49,7 @@ sealed interface SampleDestination : Node { data class Chat( val roomName: String, + val participants: List = emptyList(), ) : SampleDestination { override val id: String diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt index 8d50cfe..a4e9360 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt @@ -122,7 +122,7 @@ private fun ProfilePhoto( if (profileName != null) { paneScaffoldState.updatedMovableSharedElementOf( sharedContentState = paneScaffoldState.rememberSharedContentState( - key = "${state.roomName}-$profileName" + key = "${state.roomName}-$profileName-profile" ), state = ProfilePhotoArgs( profileName = profileName, From b8f72d810abce93bd79ab6b342559cc2c22be581 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 22 Jun 2025 19:02:16 -0400 Subject: [PATCH 32/78] Allow shared elements in certain conditions for primary to secondary --- .../MovableSharedElementTransform.kt | 65 ++++++++++++++++++- .../MovableSharedElements.kt | 15 +++-- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 08cadf2..3f89f8b 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -23,11 +23,14 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope.OverlayClip import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize import androidx.compose.animation.core.Transition +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Adaptation import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.PaneScope @@ -135,7 +138,7 @@ private class ThreePaneMovableSharedElementScope( null -> throw IllegalArgumentException( "Shared elements may only be used in non null panes" ) - // Allow shared elements in the primary or transient primary content only + // Allow movable shared elements in the primary pane only ThreePane.Primary -> delegate.movableSharedElementOf( sharedContentState = sharedContentState, boundsTransform = boundsTransform, @@ -147,10 +150,68 @@ private class ThreePaneMovableSharedElementScope( sharedElement = sharedElement ) + // In the secondary pane allow shared elements only if certain conditions match + ThreePane.Secondary -> when { + canAnimateSecondary() -> { state, modifier -> + with(hostState) { + Box(modifier) { + Box( + Modifier + .fillMaxConstraints() + .sharedElementWithCallerManagedVisibility( + sharedContentState = sharedContentState, + visible = false, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) + ) + (alternateOutgoingSharedElement ?: sharedElement)( + state, + Modifier + .fillMaxConstraints(), + ) + } + } + } + + else -> alternateOutgoingSharedElement ?: sharedElement + } + // In the other panes use the element as is - ThreePane.Secondary, ThreePane.Tertiary, ThreePane.Overlay, -> alternateOutgoingSharedElement ?: sharedElement } } + +private fun PaneScope.canAnimateSecondary(): Boolean { + if (inPredictiveBack) return false + if (!paneState.adaptations.contains(ThreePane.PrimaryToSecondary)) return false + if (paneState.adaptations.contains(Adaptation.Pop)) return false + + return true +} + +private fun Modifier.fillMaxConstraints() = + layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy( + minWidth = when { + constraints.hasBoundedWidth -> constraints.maxWidth + else -> constraints.minWidth + }, + minHeight = when { + constraints.hasBoundedHeight -> constraints.maxHeight + else -> constraints.minHeight + } + ) + ) + layout( + width = placeable.width, + height = placeable.height + ) { + placeable.place(0, 0) + } + } \ 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 73578c4..dc17bf1 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 @@ -167,13 +167,14 @@ class MovableSharedElementHostState( sharedContentState: SharedContentState, sharedElement: @Composable (S, Modifier) -> Unit, ): @Composable (S, Modifier) -> Unit { - val movableSharedElementState = keysToMovableSharedElements.getOrPut(sharedContentState.key) { - MovableSharedElementState( - sharedContentState = sharedContentState, - sharedElement = sharedElement, - onRemoved = { keysToMovableSharedElements.remove(sharedContentState.key) } - ) - }.also { it.sharedContentState = sharedContentState } + val movableSharedElementState = + keysToMovableSharedElements.getOrPut(sharedContentState.key) { + MovableSharedElementState( + sharedContentState = sharedContentState, + sharedElement = sharedElement, + onRemoved = { keysToMovableSharedElements.remove(sharedContentState.key) } + ) + }.also { it.sharedContentState = sharedContentState } // Can't really guarantee that the caller will use the same key for the right type return movableSharedElementState.moveableSharedElement From c6d0221188425444cf20e445abdda6e29b45fc82 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 06:10:37 -0400 Subject: [PATCH 33/78] Add ContentTransform lambda to MultiPaneDisplayState --- .../treenav/compose/MultiPaneDisplay.kt | 143 ++++++++++++------ .../treenav/compose/MultiPaneDisplayState.kt | 16 +- 2 files changed, 110 insertions(+), 49 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 3b1673b..94f682a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -17,8 +17,10 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -27,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle @@ -55,6 +58,10 @@ import kotlinx.coroutines.flow.collect @Stable interface MultiPaneDisplayScope { + val inPredictiveBack: Boolean + + val panes: Collection + @Composable fun Destination( pane: Pane, @@ -135,7 +142,6 @@ fun MultiPaneDisplay( slots = slots ) - val sceneStrategy = remember { MultiPanePaneSceneStrategy( state = state, @@ -165,6 +171,21 @@ fun MultiPaneDisplay( rememberViewModelStoreNavEntryDecorator(), ), sceneStrategy = sceneStrategy, + transitionSpec = { + state.transitionSpec( + sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope + ) + }, + popTransitionSpec = { + state.transitionSpec( + sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope + ) + }, + predictivePopTransitionSpec = { + state.transitionSpec( + sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope + ) + }, entryProvider = { key -> NavEntry( key = key, @@ -197,7 +218,6 @@ fun MultiPaneDisplay( } } - @Stable private class MultiPanePaneSceneStrategy( private val state: MultiPaneDisplayState, @@ -207,6 +227,8 @@ private class MultiPanePaneSceneStrategy.() -> Unit), ) : SceneStrategy { + val scenes = mutableMapOf>() + @Composable override fun calculateScene( entries: List>, @@ -220,37 +242,43 @@ private class MultiPanePaneSceneStrategy - destination.children.mapTo(mutableSetOf(), Node::id) + destination.id - } + val destination = state.destinationTransform(currentNavigationState) + + val activeIds = destination.children.mapTo(mutableSetOf(), Node::id) + destination.id + + val poppedNavigationState = state.popTransform(currentNavigationState) - val poppedBackstack = state.backStackTransform(state.popTransform(current)) + val poppedBackstack = + if (currentNavigationState == poppedNavigationState) emptyList() + else state.backStackTransform(poppedNavigationState) val mutableEntries = entries.toMutableList() MultiPaneDisplayScene( backstackIds = backstackIds, - destination = state.destinationTransform(current), + destination = destination, slots = slots, isPreviewingBack = isPreviewingBack, panesToDestinations = state.panesToDestinationsTransform, + onSceneDisposed = { scenes.remove(destination.id) }, currentPanedNavigationState = currentPanedNavigationState(), entries = entries.filter { it.id in activeIds }, // Try to match up NavEntries to state using their id and children. // Best case is O(n) where the backstack isn't shuffled. - previousEntries = poppedBackstack.map { destination -> + previousEntries = poppedBackstack.map { poppedDestination -> val index = mutableEntries.indexOfFirst { - it.id == destination.id && it.children == destination.children + it.id == poppedDestination.id && it.children == poppedDestination.children } mutableEntries.removeAt(index) }, scopeContent = content - ) + ).also { + scenes[backstackIds.last()] = it + } } } } @@ -263,56 +291,69 @@ private class MultiPaneDisplayScene( private val slots: Set, private val isPreviewingBack: () -> Boolean, private val panesToDestinations: @Composable (Destination) -> Map, + private val onSceneDisposed: () -> Unit, private val currentPanedNavigationState: SlotBasedPanedNavigationState, private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), ) : Scene { - override val key: Any = destination.id - - override val content: @Composable () -> Unit = { - - val panedNavigationState by currentPanedNavigationState.rememberUpdatedPanedNavigationState( - backStackIds = backstackIds, - panesToDestinations = panesToDestinations(destination), - slots = slots, - ) + private var panedNavigationState by mutableStateOf(currentPanedNavigationState) - val multiPaneDisplayScope: MultiPaneDisplayScope = remember { - object : MultiPaneDisplayScope { + @Stable + val multiPaneDisplayScope = object : MultiPaneDisplayScope { - @Composable - override fun Destination(pane: Pane) { - val id = panedNavigationState.destinationFor(pane)?.id - val entry = entries.firstOrNull { it.id == id } ?: return + override val inPredictiveBack: Boolean + get() = isPreviewingBack() - val paneState = panedNavigationState.slotFor(pane) - ?.let(panedNavigationState::paneStateFor) ?: return + override val panes: Collection + get() = panedNavigationState.panesToDestinations.keys - val animatedContentScope = LocalNavAnimatedContentScope.current + @Composable + override fun Destination(pane: Pane) { + val id = panedNavigationState.destinationFor(pane)?.id + val entry = entries.firstOrNull { it.id == id } ?: return - val scope = remember { - AnimatedPaneScope( - paneState = paneState, - isPreviewingBack = isPreviewingBack, - animatedContentScope = animatedContentScope, - ) - }.also { it.paneState = paneState } + val paneState = panedNavigationState.slotFor(pane) + ?.let(panedNavigationState::paneStateFor) ?: return - CompositionLocalProvider( - LocalPaneScope provides scope - ) { - entry.Content() - } - } + val animatedContentScope = LocalNavAnimatedContentScope.current - override fun adaptationsIn(pane: Pane): Set = - panedNavigationState.adaptationsIn(pane) + val scope = remember { + AnimatedPaneScope( + paneState = paneState, + isPreviewingBack = isPreviewingBack, + animatedContentScope = animatedContentScope, + ) + }.also { it.paneState = paneState } - override fun destinationIn(pane: Pane): Destination? = - panedNavigationState.destinationFor(pane) + CompositionLocalProvider( + LocalPaneScope provides scope + ) { + entry.Content() } } + + override fun adaptationsIn(pane: Pane): Set = + panedNavigationState.adaptationsIn(pane) + + override fun destinationIn(pane: Pane): Destination? = + panedNavigationState.destinationFor(pane) + } + + override val key: Any = destination.id + + override val content: @Composable () -> Unit = { + + currentPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds = backstackIds, + panesToDestinations = panesToDestinations(destination), + slots = slots, + ).also { panedNavigationState = it.value } + multiPaneDisplayScope.scopeContent() + + DisposableEffect(Unit) { + onDispose(onSceneDisposed) + } } } @@ -327,7 +368,7 @@ private fun MultiPaneDisplayState<*, NavigationState, * } @Composable -internal fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( +private fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( backStackIds: List, panesToDestinations: Map, slots: Set @@ -347,6 +388,12 @@ internal fun SlotBasedPanedNavigationState.sceneDestinationKey: String + get() { + val target = targetState as Pair<*, *> + return target.second as String + } + internal val LocalPaneScope = staticCompositionLocalOf> { throw IllegalArgumentException( "PaneScope should not be read until provided in the composition" diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index cb11068..6569fee 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -16,6 +16,9 @@ package com.tunjid.treenav.compose +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -47,6 +50,7 @@ class MultiPaneDisplayState in internal val destinationTransform: (NavigationState) -> Destination, internal val popTransform: (NavigationState) -> NavigationState, internal val onPopped: (NavigationState) -> Unit, + internal val transitionSpec: MultiPaneDisplayScope.() -> ContentTransform, internal val panesToDestinationsTransform: @Composable (Destination) -> Map, internal val renderTransform: @Composable PaneScope.(Destination) -> Unit, ) { @@ -73,13 +77,16 @@ class MultiPaneDisplayState in */ fun MultiPaneDisplayState( panes: List, + transforms: List>, navigationState: State, backStackTransform: (NavigationState) -> List, destinationTransform: (NavigationState) -> Destination, popTransform: (NavigationState) -> NavigationState, onPopped: (NavigationState) -> Unit, + transitionSpec: MultiPaneDisplayScope.() -> ContentTransform = { + NoContentTransform + }, entryProvider: (Destination) -> PaneEntry, - transforms: List>, ) = transforms.fold( initial = MultiPaneDisplayState( panes = panes, @@ -88,6 +95,7 @@ fun MultiPaneDisplayState( destinationTransform = destinationTransform, popTransform = popTransform, onPopped = onPopped, + transitionSpec = transitionSpec, panesToDestinationsTransform = { destination -> entryProvider(destination).paneTransform(destination) }, @@ -110,6 +118,7 @@ private operator fun popTransform = popTransform, onPopped = onPopped, destinationTransform = destinationTransform, + transitionSpec = transitionSpec, panesToDestinationsTransform = when (transform) { is PaneTransform -> { destination -> transform.toPanesAndDestinations( @@ -133,3 +142,8 @@ private operator fun else -> renderTransform }, ) + +private val NoContentTransform = ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, +) \ No newline at end of file From 79dcd365b9f2fa19887bf6d0fb2db81b2d7d5a67 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 06:17:35 -0400 Subject: [PATCH 34/78] Remove Modifier.composed from Modifier.paneSharedElement --- .../ThreePaneSharedTransitionScope.kt | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt index 1b3b087..f307732 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt @@ -72,28 +72,25 @@ private class ThreePaneSharedTransitionScope @OptIn( visible: Boolean?, zIndexInOverlay: Float, clipInOverlayDuringTransition: OverlayClip, - ): Modifier = composed { - - when (paneScope.paneState.pane) { - null -> throw IllegalArgumentException( - "Shared elements may only be used in non null panes" - ) - // Allow shared elements in the primary or transient primary content only - ThreePane.Primary -> sharedElement( - sharedContentState = sharedContentState, - animatedVisibilityScope = paneScope, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - ) + ): Modifier = when (paneScope.paneState.pane) { + null -> throw IllegalArgumentException( + "Shared elements may only be used in non null panes" + ) + // Allow shared elements in the primary or transient primary content only + ThreePane.Primary -> sharedElement( + sharedContentState = sharedContentState, + animatedVisibilityScope = paneScope, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) - // In the other panes use the element as is - ThreePane.Secondary, - ThreePane.Tertiary, - ThreePane.Overlay, - -> this - } + // In the other panes use the element as is + ThreePane.Secondary, + ThreePane.Tertiary, + ThreePane.Overlay, + -> this } } \ No newline at end of file From c2058d585c561465250f9bd459659c53c27bd94f Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 07:29:47 -0400 Subject: [PATCH 35/78] Use paneShareElement forr toolbar titles, update shared element in AvatarScreen --- .../com/tunjid/demo/common/ui/AppBars.kt | 60 +++++++------------ .../demo/common/ui/avatar/AvatarScreen.kt | 2 +- .../tunjid/demo/common/ui/chat/ChatScreen.kt | 10 ++-- .../common/ui/chatrooms/ChatRoomsScreen.kt | 22 +++++-- .../demo/common/ui/profile/ProfileScreen.kt | 11 +++- 5 files changed, 56 insertions(+), 49 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt index 1c5251d..668394e 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt @@ -17,19 +17,19 @@ package com.tunjid.demo.common.ui +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tunjid.composables.collapsingheader.CollapsingHeaderState @@ -50,42 +50,28 @@ fun rememberAppBarCollapsingHeaderState( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SampleTopAppBar( title: @Composable () -> Unit, onBackPressed: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - TopAppBar( - title = title, - navigationIcon = { - if (onBackPressed != null) IconButton( - onClick = onBackPressed, - content = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - ) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - modifier = modifier, - ) + Row( + modifier = modifier + .statusBarsPadding() + .height(56.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onBackPressed != null) IconButton( + onClick = onBackPressed, + content = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + ) + title() + } } - -@Composable -fun SampleTopAppBar( - title: String, - onBackPressed: (() -> Unit)? = null, - modifier: Modifier = Modifier, -) { - SampleTopAppBar( - title = { - Text(text = title) - }, - onBackPressed = onBackPressed, - modifier = modifier, - ) -} \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt index 72e4415..150de85 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt @@ -48,7 +48,7 @@ fun AvatarScreen( val profileName = state.profileName ?: state.profile?.name ?: "" paneScaffoldState.updatedMovableSharedElementOf( sharedContentState = paneScaffoldState.rememberSharedContentState( - key = "${state.roomName}-$profileName" + key = "${state.roomName}-$profileName-profile" ), state = ProfilePhotoArgs( profileName = profileName, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt index b9b8cac..baa107f 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt @@ -106,19 +106,21 @@ private fun ChatTitle( roomName: String, participants: List, paneScaffoldState: PaneScaffoldState -) { +) = with(paneScaffoldState) { Row( verticalAlignment = Alignment.CenterVertically ) { Text( - text = roomName + modifier = Modifier + .paneSharedElement(rememberSharedContentState("title")), + text = roomName, ) Spacer( modifier = Modifier .width(16.dp) ) participants.forEachIndexed { index, participant -> - paneScaffoldState.updatedMovableSharedElementOf( + updatedMovableSharedElementOf( sharedContentState = paneScaffoldState.rememberSharedContentState( key = "${roomName}-${participant}" ), @@ -225,7 +227,7 @@ fun Message( ) { paneScaffoldState.updatedMovableSharedElementOf( sharedContentState = paneScaffoldState.rememberSharedContentState( - key ="$roomName-${item.sender.name}-profile" + key = "$roomName-${item.sender.name}-profile" ), state = ProfilePhotoArgs( profileName = item.sender.name, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt index 1c218c3..be90cdc 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt @@ -19,7 +19,6 @@ package com.tunjid.demo.common.ui.chatrooms import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -68,7 +67,10 @@ fun ChatRoomsScreen( state = headerState, modifier = modifier, headerContent = { - Header(headerState) + Header( + headerState = headerState, + paneScaffoldState = paneScaffoldState, + ) }, body = { ChatRooms( @@ -80,8 +82,12 @@ fun ChatRoomsScreen( ) } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable -private fun Header(headerState: CollapsingHeaderState) { +private fun Header( + headerState: CollapsingHeaderState, + paneScaffoldState: PaneScaffoldState, +) = with(paneScaffoldState) { Box { Box( modifier = Modifier @@ -95,7 +101,13 @@ private fun Header(headerState: CollapsingHeaderState) { } ) SampleTopAppBar( - title = "Chat Rooms", + title = { + Text( + modifier = Modifier + .paneSharedElement(rememberSharedContentState("title")), + text = "Chat Rooms", + ) + }, onBackPressed = null ) } @@ -173,7 +185,7 @@ fun ChatRoomListItem( } } -@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ChatRoomParticipants( paneScaffoldState: PaneScaffoldState, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt index a4e9360..0feca02 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt @@ -89,13 +89,14 @@ fun ProfileScreen( ) } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun ProfileHeader( state: State, paneScaffoldState: PaneScaffoldState, modifier: Modifier = Modifier, onBackPressed: (() -> Unit)?, -) { +) = with(paneScaffoldState) { Box( modifier = Modifier.heightIn(min = 400.dp) ) { @@ -105,7 +106,13 @@ private fun ProfileHeader( modifier = modifier ) SampleTopAppBar( - title = if (state.profileName == null) "Me" else "Profile", + title = { + Text( + modifier = Modifier + .paneSharedElement(rememberSharedContentState("title")), + text = if (state.profileName == null) "Me" else "Profile", + ) + }, onBackPressed = onBackPressed, ) } From a24b3b7b93ff8873d9a19a05724c30dca1fae3cc Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 14:42:42 -0400 Subject: [PATCH 36/78] Encapsulate PaneEntry in MultiPaneDisplayState --- .../treenav/compose/MultiPaneDisplay.kt | 36 +++---------- .../treenav/compose/MultiPaneDisplayState.kt | 50 ++++++++++++++++--- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 94f682a..3dce514 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -38,8 +38,8 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.Keys.children -import com.tunjid.treenav.compose.Keys.id +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.children +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.id import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator import com.tunjid.treenav.compose.navigation3.runtime.NavEntry import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator @@ -186,22 +186,7 @@ fun MultiPaneDisplay( sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope ) }, - entryProvider = { key -> - NavEntry( - key = key, - contentKey = key.id, - metadata = mapOf( - Keys.ID_KEY to key.id, - Keys.DESTINATION_KEY to key, - Keys.CHILDREN_KEY to key.children, - ), - content = { destination -> - val scope = LocalPaneScope.current - @Suppress("UNCHECKED_CAST") - state.renderTransform(scope as PaneScope, destination) - }, - ) - }, + entryProvider = state.navEntryProvider, ) NavigationEventHandler( @@ -400,15 +385,8 @@ internal val LocalPaneScope = staticCompositionLocalOf> { ) } -internal object Keys { - internal const val ID_KEY = "com.tunjid.treenav.compose.id" - internal const val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" - internal const val CHILDREN_KEY = "com.tunjid.treenav.compose.children" - - internal val NavEntry<*>.id get() = metadata[ID_KEY] as String - internal val NavEntry<*>.children get() = metadata[CHILDREN_KEY] - - internal inline fun NavEntry<*>.destination() = - metadata[DESTINATION_KEY] as T - +@Composable +internal fun localPaneScope(): PaneScope { + @Suppress("UNCHECKED_CAST") + return LocalPaneScope.current as PaneScope } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 6569fee..cedcf31 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry import com.tunjid.treenav.compose.transforms.PaneTransform import com.tunjid.treenav.compose.transforms.RenderTransform import com.tunjid.treenav.compose.transforms.Transform @@ -51,10 +52,40 @@ class MultiPaneDisplayState in internal val popTransform: (NavigationState) -> NavigationState, internal val onPopped: (NavigationState) -> Unit, internal val transitionSpec: MultiPaneDisplayScope.() -> ContentTransform, + internal val entryProvider: (Destination) -> PaneEntry, internal val panesToDestinationsTransform: @Composable (Destination) -> Map, - internal val renderTransform: @Composable PaneScope.(Destination) -> Unit, + internal val renderTransform: @Composable PaneScope.(PaneEntry, Destination) -> Unit, ) { internal val backPreviewState = mutableStateOf(false) + + internal val navEntryProvider = { destination: Destination -> + val paneEntry = entryProvider(destination) + NavEntry( + key = destination, + contentKey = destination.id, + metadata = mapOf( + ID_KEY to destination.id, + DESTINATION_KEY to destination, + CHILDREN_KEY to destination.children, + ), + content = { innerDestination -> + renderTransform(localPaneScope(),paneEntry, innerDestination) + }, + ) + } + + companion object { + private const val ID_KEY = "com.tunjid.treenav.compose.id" + private const val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" + private const val CHILDREN_KEY = "com.tunjid.treenav.compose.children" + + internal val NavEntry<*>.id get() = metadata[ID_KEY] as String + internal val NavEntry<*>.children get() = metadata[CHILDREN_KEY] + + @Suppress("UNCHECKED_CAST") + internal inline fun NavEntry<*>.destination() = + metadata[DESTINATION_KEY] as T + } } /** @@ -96,12 +127,12 @@ fun MultiPaneDisplayState( popTransform = popTransform, onPopped = onPopped, transitionSpec = transitionSpec, + entryProvider = entryProvider, panesToDestinationsTransform = { destination -> entryProvider(destination).paneTransform(destination) }, - renderTransform = { destination -> - val nav = entryProvider(destination) - nav.content(this, destination) + renderTransform = { paneEntry, destination -> + paneEntry.content(this, destination) } ), operation = MultiPaneDisplayState::plus @@ -119,6 +150,7 @@ private operator fun onPopped = onPopped, destinationTransform = destinationTransform, transitionSpec = transitionSpec, + entryProvider = entryProvider, panesToDestinationsTransform = when (transform) { is PaneTransform -> { destination -> transform.toPanesAndDestinations( @@ -130,11 +162,17 @@ private operator fun else -> panesToDestinationsTransform }, renderTransform = when (transform) { - is RenderTransform -> { destination -> + is RenderTransform -> { paneEntry, destination -> with(transform) { Render( destination = destination, - previousTransform = renderTransform, + previousTransform = previous@{ innerDestination -> + renderTransform( + this@previous, + paneEntry, + innerDestination, + ) + }, ) } } From 973804def9d6515c962b0045098c6b77199bdbf6 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 14:59:55 -0400 Subject: [PATCH 37/78] Simplify declaration of MultiPaneDisplay --- .../treenav/compose/MultiPaneDisplay.kt | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 3dce514..4aa00f6 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -18,6 +18,7 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -152,6 +153,12 @@ fun MultiPaneDisplay( ) } + val transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = { + state.transitionSpec( + sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope + ) + } + NavDisplay( backStack = backStack, modifier = modifier, @@ -171,21 +178,9 @@ fun MultiPaneDisplay( rememberViewModelStoreNavEntryDecorator(), ), sceneStrategy = sceneStrategy, - transitionSpec = { - state.transitionSpec( - sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope - ) - }, - popTransitionSpec = { - state.transitionSpec( - sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope - ) - }, - predictivePopTransitionSpec = { - state.transitionSpec( - sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope - ) - }, + transitionSpec = transitionSpec, + popTransitionSpec = transitionSpec, + predictivePopTransitionSpec = transitionSpec, entryProvider = state.navEntryProvider, ) From 4490c63f1bc412b0665c49fe6ce0b13f9145f0a6 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 18:48:16 -0400 Subject: [PATCH 38/78] Support pane level animations --- .../treenav/compose/threepane/ThreePane.kt | 34 ++++++++++++++++--- .../treenav/compose/MultiPaneDisplay.kt | 23 ++++++++++++- .../treenav/compose/MultiPaneDisplayState.kt | 23 ++++++++++--- .../com/tunjid/treenav/compose/PaneEntry.kt | 5 ++- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 1f930a9..f9ca092 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -24,6 +24,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Adaptation import com.tunjid.treenav.compose.Adaptation.Swap import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.PaneEntry @@ -78,19 +79,25 @@ enum class ThreePane { * - A navigation destination moves between the [ThreePane.Primary] and * [ThreePane.TransientPrimary] panes, the pane animations are not run. * - * @param enterTransition the transition to run for the entering pane when permitted. - * @param exitTransition the transition to run for the exiting pane when permitted. + * @param enterTransition the transition to run for the entering pane. + * @param exitTransition the transition to run for the exiting pane. * @param paneMapping the mapping of panes to navigation destinations. * @param render the Composable for rendering the current destination. */ fun threePaneEntry( - enterTransition: PaneScope.() -> EnterTransition = { DefaultFadeIn }, - exitTransition: PaneScope.() -> ExitTransition = { DefaultFadeOut }, + enterTransition: PaneScope.() -> EnterTransition = { + if (canAnimate()) DefaultFadeIn else EnterTransition.None + }, + exitTransition: PaneScope.() -> ExitTransition = { + if (canAnimate()) DefaultFadeOut else ExitTransition.None + }, paneMapping: @Composable (R) -> Map = { mapOf(ThreePane.Primary to it) }, render: @Composable (PaneScope.(R) -> Unit), ) = PaneEntry( + enterTransition = enterTransition, + exitTransition = exitTransition, paneTransform = paneMapping, content = render ) @@ -106,3 +113,22 @@ private val DefaultFadeIn = fadeIn( private val DefaultFadeOut = fadeOut( animationSpec = RouteTransitionAnimationSpec, ) + +private fun PaneScope.canAnimate() = + when { + inPredictiveBack && isActive -> false + paneState.adaptations.any { adaptation -> + adaptation is Adaptation.Pop + } -> true + + else -> when (val pane = paneState.pane) { + ThreePane.Primary, + ThreePane.Secondary, + ThreePane.Tertiary -> paneState.adaptations.any { adaptation -> + adaptation is Swap<*> && adaptation.from == pane + } + + ThreePane.Overlay, + null -> true + } + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 4aa00f6..aedeef6 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -19,6 +19,9 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -41,6 +44,8 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.children import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.id +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.paneEnterTransition +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.paneExitTransition import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator import com.tunjid.treenav.compose.navigation3.runtime.NavEntry import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator @@ -308,7 +313,23 @@ private class MultiPaneDisplayScene( CompositionLocalProvider( LocalPaneScope provides scope ) { - entry.Content() + with(scope) { + val enterTransition = entry.paneEnterTransition(this) + val exitTransition = entry.paneExitTransition(this) + val shouldAnimate = enterTransition != EnterTransition.None + || exitTransition != ExitTransition.None + Box( + modifier = + if (shouldAnimate) Modifier.animateEnterExit( + enterTransition, + exitTransition + ) + else Modifier, + content = { + entry.Content() + } + ) + } } } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index cedcf31..b810df1 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -67,17 +67,23 @@ class MultiPaneDisplayState in ID_KEY to destination.id, DESTINATION_KEY to destination, CHILDREN_KEY to destination.children, + PANE_ENTER_TRANSITION_KEY to paneEntry.enterTransition, + PANE_EXIT_TRANSITION_KEY to paneEntry.exitTransition, ), content = { innerDestination -> - renderTransform(localPaneScope(),paneEntry, innerDestination) + renderTransform(localPaneScope(), paneEntry, innerDestination) }, ) } - companion object { + companion object { private const val ID_KEY = "com.tunjid.treenav.compose.id" private const val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" private const val CHILDREN_KEY = "com.tunjid.treenav.compose.children" + private const val PANE_ENTER_TRANSITION_KEY = + "com.tunjid.treenav.compose.pane.enter.transition" + private const val PANE_EXIT_TRANSITION_KEY = + "com.tunjid.treenav.compose.pane.exit.transition" internal val NavEntry<*>.id get() = metadata[ID_KEY] as String internal val NavEntry<*>.children get() = metadata[CHILDREN_KEY] @@ -85,6 +91,14 @@ class MultiPaneDisplayState in @Suppress("UNCHECKED_CAST") internal inline fun NavEntry<*>.destination() = metadata[DESTINATION_KEY] as T + + @Suppress("UNCHECKED_CAST") + internal inline val NavEntry<*>.paneEnterTransition + get() = metadata[PANE_ENTER_TRANSITION_KEY] as PaneScope<*, *>.() -> EnterTransition + + @Suppress("UNCHECKED_CAST") + internal inline val NavEntry<*>.paneExitTransition + get() = metadata[PANE_EXIT_TRANSITION_KEY] as PaneScope<*, *>.() -> ExitTransition } } @@ -131,8 +145,8 @@ fun MultiPaneDisplayState( panesToDestinationsTransform = { destination -> entryProvider(destination).paneTransform(destination) }, - renderTransform = { paneEntry, destination -> - paneEntry.content(this, destination) + renderTransform = transform@{ paneEntry, destination -> + paneEntry.content(this@transform, destination) } ), operation = MultiPaneDisplayState::plus @@ -184,4 +198,5 @@ private operator fun private val NoContentTransform = ContentTransform( targetContentEnter = EnterTransition.None, initialContentExit = ExitTransition.None, + sizeTransform = null, ) \ No newline at end of file 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 index 5a28461..bf0c241 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt @@ -1,9 +1,10 @@ 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 -import com.tunjid.treenav.compose.transforms.RenderTransform /** * Provides the logic used to select, configure and place a navigation [Destination] for each @@ -11,6 +12,8 @@ import com.tunjid.treenav.compose.transforms.RenderTransform */ @Stable class PaneEntry( + internal val enterTransition: PaneScope.() -> EnterTransition, + internal val exitTransition: PaneScope.() -> ExitTransition, internal val paneTransform: @Composable (Destination) -> Map, internal val content: @Composable PaneScope.(Destination) -> Unit, ) \ No newline at end of file From fee564631ce5d0a974636f9da44fbcbd670b8179 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 18:54:21 -0400 Subject: [PATCH 39/78] Pane animation tweaks --- .../kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index f9ca092..8fbb721 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -117,6 +117,9 @@ private val DefaultFadeOut = fadeOut( private fun PaneScope.canAnimate() = when { inPredictiveBack && isActive -> false + paneState.adaptations.any { adaptation -> + adaptation is Adaptation.Same + } -> false paneState.adaptations.any { adaptation -> adaptation is Adaptation.Pop } -> true From 432e8d9ba7ba19db8307181910774cbcfdebe21f Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 19:04:05 -0400 Subject: [PATCH 40/78] More rules for adaptive animations --- .../kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 8fbb721..4f2522b 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -120,8 +120,11 @@ private fun PaneScope.canAnimate() = paneState.adaptations.any { adaptation -> adaptation is Adaptation.Same } -> false + paneState.adaptations.any { adaptation -> adaptation is Adaptation.Pop + } && paneState.adaptations.none { + it is Swap<*> } -> true else -> when (val pane = paneState.pane) { From d29b28fed81d5fae2601cf46c9fc172a7f0670c9 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 19:08:40 -0400 Subject: [PATCH 41/78] More adaptive animation rules --- .../kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 4f2522b..ec319af 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -122,7 +122,7 @@ private fun PaneScope.canAnimate() = } -> false paneState.adaptations.any { adaptation -> - adaptation is Adaptation.Pop + adaptation is Adaptation.Pop || adaptation is Adaptation.Change } && paneState.adaptations.none { it is Swap<*> } -> true From 57e35e36a92e72d3daa0cf035fe26ff7cefdeaa4 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 23 Jun 2025 20:02:16 -0400 Subject: [PATCH 42/78] Add static NavigationEventDispatcherOwner for desktop and iOS --- .../ui/LocalNavigationEventDispatcherOwner.jvm.kt | 8 +++++++- .../ui/LocalNavigationEventDispatcherOwner.native.kt | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt index 0b5d134..fb33884 100644 --- a/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt +++ b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt @@ -17,8 +17,14 @@ package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable +import androidx.navigationevent.NavigationEventDispatcher import androidx.navigationevent.NavigationEventDispatcherOwner @Composable internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = - null \ No newline at end of file + Owner + +private object Owner: NavigationEventDispatcherOwner { + override val navigationEventDispatcher: NavigationEventDispatcher = + NavigationEventDispatcher() +} \ No newline at end of file diff --git a/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt index 0b5d134..fb33884 100644 --- a/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt +++ b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt @@ -17,8 +17,14 @@ package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable +import androidx.navigationevent.NavigationEventDispatcher import androidx.navigationevent.NavigationEventDispatcherOwner @Composable internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = - null \ No newline at end of file + Owner + +private object Owner: NavigationEventDispatcherOwner { + override val navigationEventDispatcher: NavigationEventDispatcher = + NavigationEventDispatcher() +} \ No newline at end of file From 0163ff3f276bf3e3353a85b193f67f07faddd6e5 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 07:29:31 -0400 Subject: [PATCH 43/78] Use viewmodel coroutine scope --- .../demo/common/ui/ViewModelUtilities.kt | 24 +++++++++++++++++++ .../demo/common/ui/avatar/AvatarViewModel.kt | 4 ++-- .../tunjid/demo/common/ui/avatar/PaneEntry.kt | 6 ++--- .../demo/common/ui/chat/ChatViewModel.kt | 4 ++-- .../tunjid/demo/common/ui/chat/PaneEntry.kt | 6 ++--- .../common/ui/chatrooms/ChatRoomsViewModel.kt | 4 ++-- .../demo/common/ui/chatrooms/PaneEntry.kt | 6 ++--- .../com/tunjid/demo/common/ui/me/PaneEntry.kt | 6 ++--- .../demo/common/ui/profile/PaneEntry.kt | 6 ++--- .../common/ui/profile/ProfileViewModel.kt | 5 ++-- 10 files changed, 42 insertions(+), 29 deletions(-) create mode 100644 sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ViewModelUtilities.kt diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ViewModelUtilities.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ViewModelUtilities.kt new file mode 100644 index 0000000..8056f80 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ViewModelUtilities.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +fun viewModelCoroutineScope() = + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt index 8f8ccc9..1947ca8 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt @@ -16,7 +16,6 @@ package com.tunjid.demo.common.ui.avatar -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import com.tunjid.demo.common.ui.data.NavigationAction import com.tunjid.demo.common.ui.data.NavigationRepository @@ -31,11 +30,12 @@ import com.tunjid.mutator.coroutines.mapToMutation import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow class AvatarViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, profileName: String?, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt index d663eb9..e3feb24 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt @@ -19,14 +19,13 @@ package com.tunjid.demo.common.ui.avatar import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneScaffold import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.threePaneEntry @@ -41,10 +40,9 @@ fun avatarPaneEntry() = threePaneEntry( }, render = { destination -> check(destination is SampleDestination.Avatar) - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { AvatarViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), profileName = destination.profileName, roomName = destination.roomName, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt index e05d1d5..1b146ec 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt @@ -16,7 +16,6 @@ package com.tunjid.demo.common.ui.chat -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import com.tunjid.demo.common.ui.data.ChatRoom import com.tunjid.demo.common.ui.data.ChatsRepository @@ -37,13 +36,14 @@ import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop import com.tunjid.treenav.push import com.tunjid.treenav.swap +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest class ChatViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, chatsRepository: ChatsRepository = ChatsRepository, profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt index ade5801..1b9bf10 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt @@ -18,9 +18,7 @@ package com.tunjid.demo.common.ui.chat import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -29,6 +27,7 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.threePaneEntry @@ -41,10 +40,9 @@ fun chatPaneEntry() = threePaneEntry( }, render = { destination -> check(destination is SampleDestination.Chat) - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ChatViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), chat = destination, ) } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt index db870bb..42fa786 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt @@ -16,7 +16,6 @@ package com.tunjid.demo.common.ui.chatrooms -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import com.tunjid.demo.common.ui.data.ChatRoom import com.tunjid.demo.common.ui.data.ChatsRepository @@ -31,11 +30,12 @@ import com.tunjid.mutator.coroutines.actionStateFlowMutator import com.tunjid.mutator.coroutines.mapToMutation import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.push +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow class ChatRoomsViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, chatsRepository: ChatsRepository = ChatsRepository, navigationRepository: NavigationRepository = NavigationRepository, ) : ViewModel(coroutineScope), diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt index c426e9b..ce21e94 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt @@ -18,9 +18,7 @@ package com.tunjid.demo.common.ui.chatrooms import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -28,15 +26,15 @@ import com.tunjid.demo.common.ui.PaneScaffold import com.tunjid.demo.common.ui.data.ChatsRepository import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.threePaneEntry fun chatRoomPaneEntry( ) = threePaneEntry( render = { - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ChatRoomsViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), chatsRepository = ChatsRepository ) } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt index 8278117..6e0cd8d 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt @@ -18,9 +18,7 @@ package com.tunjid.demo.common.ui.me import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -29,15 +27,15 @@ import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.profile.ProfileScreen import com.tunjid.demo.common.ui.profile.ProfileViewModel import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.threePaneEntry fun mePaneEntry( ) = threePaneEntry( render = { - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ProfileViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), profileName = null, roomName = null, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt index b1c61b7..10c3031 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt @@ -18,9 +18,7 @@ package com.tunjid.demo.common.ui.profile import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -29,6 +27,7 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.threePaneEntry @@ -43,10 +42,9 @@ fun profilePaneEntry() = threePaneEntry( }, render = { destination -> check(destination is SampleDestination.Profile) - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ProfileViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), profileName = destination.profileName, roomName = destination.roomName, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt index a2ec738..23436ca 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt @@ -16,9 +16,7 @@ package com.tunjid.demo.common.ui.profile -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel -import com.tunjid.demo.common.ui.chatrooms.Action.Navigation import com.tunjid.demo.common.ui.data.NavigationAction import com.tunjid.demo.common.ui.data.NavigationRepository import com.tunjid.demo.common.ui.data.Profile @@ -34,11 +32,12 @@ import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop import com.tunjid.treenav.push +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow class ProfileViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, profileName: String?, From 32f662debfe5f0bc42e7aaec3d6cdee0fdc47d82 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 07:48:08 -0400 Subject: [PATCH 44/78] Try to cut down allocations during pane animations --- .../treenav/compose/MultiPaneDisplay.kt | 17 +++-- .../com/tunjid/treenav/compose/PaneScope.kt | 68 +++++++++++++++---- .../compose/SlotBasedPanedNavigationState.kt | 53 ++++----------- 3 files changed, 77 insertions(+), 61 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index aedeef6..7ebf82a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -42,6 +42,8 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.AnimatedPaneScope.Companion.paneScope +import com.tunjid.treenav.compose.AnimatedPaneScope.Companion.update import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.children import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.id import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.paneEnterTransition @@ -296,19 +298,22 @@ private class MultiPaneDisplayScene( override fun Destination(pane: Pane) { val id = panedNavigationState.destinationFor(pane)?.id val entry = entries.firstOrNull { it.id == id } ?: return - - val paneState = panedNavigationState.slotFor(pane) - ?.let(panedNavigationState::paneStateFor) ?: return + val slot = panedNavigationState.slotFor(pane) ?: return val animatedContentScope = LocalNavAnimatedContentScope.current val scope = remember { - AnimatedPaneScope( - paneState = paneState, + panedNavigationState.paneScope( + slot = slot, isPreviewingBack = isPreviewingBack, animatedContentScope = animatedContentScope, ) - }.also { it.paneState = paneState } + }.also { + panedNavigationState.update( + animatedPaneScope = it, + slot = slot, + ) + } CompositionLocalProvider( LocalPaneScope provides scope 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 908c67e..b53a6cb 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 @@ -58,13 +58,14 @@ interface PaneScope : AnimatedVisibilityScope { * An implementation of [PaneScope] that supports animations and shared elements */ @Stable -internal class AnimatedPaneScope( - paneState: PaneState, +internal class AnimatedPaneScope private constructor( + private val slotPaneState: SlotPaneState, val isPreviewingBack: () -> Boolean, val animatedContentScope: AnimatedContentScope ) : PaneScope, AnimatedVisibilityScope by animatedContentScope { - override var paneState by mutableStateOf(paneState) + override val paneState: PaneState + get() = slotPaneState override val isActive: Boolean by derivedStateOf { val isEntering = animatedContentScope.transition.targetState == EnterExitState.Visible @@ -74,6 +75,56 @@ internal class AnimatedPaneScope( override val inPredictiveBack: Boolean get() = isPreviewingBack() + + companion object { + /** + * [Slot] based implementation of [PaneState] + */ + @Stable + private class SlotPaneState( + slot: Slot?, + previousDestination: Destination?, + currentDestination: Destination?, + pane: Pane?, + adaptations: Set, + ) : PaneState { + var slot: Slot? by mutableStateOf(slot) + val previousDestination: Destination? by mutableStateOf(previousDestination) + override val currentDestination: Destination? by mutableStateOf(currentDestination) + override var pane: Pane? by mutableStateOf(pane) + override var adaptations: Set by mutableStateOf(adaptations) + } + + fun SlotBasedPanedNavigationState.paneScope( + slot: Slot, + isPreviewingBack: () -> Boolean, + animatedContentScope: AnimatedContentScope + ) = withPaneAndDestination(slot) { pane, destination -> + AnimatedPaneScope( + slotPaneState = SlotPaneState( + slot = slot, + currentDestination = destination, + previousDestination = previousPanesToDestinations[pane], + pane = pane, + adaptations = pane?.let(::adaptationsIn) ?: emptySet(), + ), + isPreviewingBack = isPreviewingBack, + animatedContentScope = animatedContentScope + ) + } + + fun SlotBasedPanedNavigationState.update( + animatedPaneScope: AnimatedPaneScope, + slot: Slot, + ) { + withPaneAndDestination(slot) { pane, _ -> + animatedPaneScope.slotPaneState.slot = slot + animatedPaneScope.slotPaneState.pane = pane + animatedPaneScope.slotPaneState.adaptations = + pane?.let(::adaptationsIn) ?: emptySet() + } + } + } } /** @@ -86,17 +137,6 @@ sealed interface PaneState { val adaptations: Set } -/** - * [Slot] based implementation of [PaneState] - */ -internal data class SlotPaneState( - val slot: Slot?, - val previousDestination: Destination?, - override val currentDestination: Destination?, - override val pane: Pane?, - override val adaptations: Set, -) : PaneState - /** * A spot taken by an [PaneEntry] that may be moved in from pane to pane. */ 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 3208ce5..c3570f0 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 @@ -70,18 +70,14 @@ internal data class SlotBasedPanedNavigationState( ) } - internal fun paneStateFor( + internal inline fun withPaneAndDestination( slot: Slot, - ): PaneState { + crossinline block: + SlotBasedPanedNavigationState.(pane: Pane?, destination: Destination?) -> T + ): T { val node = destinationFor(slot) val pane = node?.let(::paneFor) - return SlotPaneState( - slot = slot, - currentDestination = node, - previousDestination = previousPanesToDestinations[pane], - pane = pane, - adaptations = pane?.let(::adaptationsIn) ?: emptySet(), - ) + return block(pane, node) } internal fun slotFor( @@ -115,14 +111,18 @@ internal data class SlotBasedPanedNavigationState( ): Set { val swaps = swapAdaptations.filter { pane in it } val adaptations = if (swaps.isEmpty()) when (panesToDestinations[pane]?.id) { - previousPanesToDestinations[pane]?.id -> setOf(Adaptation.Same) - else -> setOf(Adaptation.Change) + previousPanesToDestinations[pane]?.id -> SameAdaptations + else -> ChangeAdaptations } else swaps.toSet() return if (isPop) adaptations + Adaptation.Pop else adaptations } } +private val SameAdaptations = setOf(Adaptation.Same) +private val ChangeAdaptations = setOf(Adaptation.Change) + + /** * A method that adapts changes in navigation to different panes while allowing for them * to be animated easily. @@ -208,33 +208,4 @@ internal fun SlotBasedPanedNavigationState.adaptTo( destinationIdsAnimatingOut = previous.destinationIdsAnimatingOut, ) -} - -/** - * Checks if any of the new routes coming in has any conflicts with those animating out. - */ -internal fun SlotBasedPanedNavigationState.hasConflictingRoutes(): Boolean = - panesToDestinations.keys - .map(::destinationFor) - .any { - it?.id?.let(destinationIdsAnimatingOut::contains) == true - } - -/** - * Trims unneeded metadata from the [SlotBasedPanedNavigationState] - */ -internal fun SlotBasedPanedNavigationState.prune(): SlotBasedPanedNavigationState = - copy( - destinationIdsToAdaptiveSlots = destinationIdsToAdaptiveSlots.filter { (routeId) -> - if (routeId == null) return@filter false - backStackIds.contains(routeId) - || destinationIdsAnimatingOut.contains(routeId) - || previousPanesToDestinations.values.map { it?.id }.toSet().contains(routeId) - }, - previousPanesToDestinations = previousPanesToDestinations.filter { (_, route) -> - if (route == null) return@filter false - backStackIds.contains(route.id) - || destinationIdsAnimatingOut.contains(route.id) - || previousPanesToDestinations.values.map { it?.id }.toSet().contains(route.id) - } - ) \ No newline at end of file +} \ No newline at end of file From 3cb1a6af4d5941688e07a89d5789e73bcb688833 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 07:53:20 -0400 Subject: [PATCH 45/78] Cutting down on more allocations --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 7ebf82a..9dd9c47 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -319,8 +319,12 @@ private class MultiPaneDisplayScene( LocalPaneScope provides scope ) { with(scope) { - val enterTransition = entry.paneEnterTransition(this) - val exitTransition = entry.paneExitTransition(this) + val enterTransition = remember(scope.isPreviewingBack) { + entry.paneEnterTransition(this) + } + val exitTransition = remember(scope.isPreviewingBack) { + entry.paneExitTransition(this) + } val shouldAnimate = enterTransition != EnterTransition.None || exitTransition != ExitTransition.None Box( From f09f75b6432ebb6b6ff2e40c2026aa43149e7254 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 07:59:54 -0400 Subject: [PATCH 46/78] More allocation optimizations --- .../compose/SlotBasedPanedNavigationState.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 c3570f0..737af33 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 @@ -109,12 +109,15 @@ internal data class SlotBasedPanedNavigationState( fun adaptationsIn( pane: Pane, ): Set { - val swaps = swapAdaptations.filter { pane in it } - val adaptations = if (swaps.isEmpty()) when (panesToDestinations[pane]?.id) { - previousPanesToDestinations[pane]?.id -> SameAdaptations - else -> ChangeAdaptations + val adaptations = when { + swapAdaptations.any { pane in it } -> swapAdaptations.filterTo(mutableSetOf()) { + pane in it + } + else -> when (panesToDestinations[pane]?.id) { + previousPanesToDestinations[pane]?.id -> SameAdaptations + else -> ChangeAdaptations + } } - else swaps.toSet() return if (isPop) adaptations + Adaptation.Pop else adaptations } } @@ -122,7 +125,6 @@ internal data class SlotBasedPanedNavigationState( private val SameAdaptations = setOf(Adaptation.Same) private val ChangeAdaptations = setOf(Adaptation.Change) - /** * A method that adapts changes in navigation to different panes while allowing for them * to be animated easily. From 61b612201fe4dd7c57d6ecd616e3482ff11c2659 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 08:13:00 -0400 Subject: [PATCH 47/78] More optimizations --- .../com/tunjid/treenav/compose/PaneScope.kt | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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 b53a6cb..ece15aa 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 @@ -22,8 +22,10 @@ import androidx.compose.animation.EnterExitState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import com.tunjid.treenav.Node import kotlin.jvm.JvmInline @@ -82,6 +84,7 @@ internal class AnimatedPaneScope private constructor( */ @Stable private class SlotPaneState( + panedNavigationStateHash: Int, slot: Slot?, previousDestination: Destination?, currentDestination: Destination?, @@ -90,9 +93,12 @@ internal class AnimatedPaneScope private constructor( ) : PaneState { var slot: Slot? by mutableStateOf(slot) val previousDestination: Destination? by mutableStateOf(previousDestination) + override val currentDestination: Destination? by mutableStateOf(currentDestination) override var pane: Pane? by mutableStateOf(pane) override var adaptations: Set by mutableStateOf(adaptations) + + var lastPanedNavigationStateHash by mutableIntStateOf(panedNavigationStateHash) } fun SlotBasedPanedNavigationState.paneScope( @@ -102,6 +108,7 @@ internal class AnimatedPaneScope private constructor( ) = withPaneAndDestination(slot) { pane, destination -> AnimatedPaneScope( slotPaneState = SlotPaneState( + panedNavigationStateHash = this@paneScope.hashCode(), slot = slot, currentDestination = destination, previousDestination = previousPanesToDestinations[pane], @@ -118,10 +125,20 @@ internal class AnimatedPaneScope private constructor( slot: Slot, ) { withPaneAndDestination(slot) { pane, _ -> - animatedPaneScope.slotPaneState.slot = slot - animatedPaneScope.slotPaneState.pane = pane - animatedPaneScope.slotPaneState.adaptations = - pane?.let(::adaptationsIn) ?: emptySet() + val state = animatedPaneScope.slotPaneState + val panedNavigationStateHash = this@update.hashCode() + + if (state.slot == slot + && state.pane == pane + && state.lastPanedNavigationStateHash == panedNavigationStateHash + ) return@withPaneAndDestination + + Snapshot.withMutableSnapshot { + state.slot = slot + state.pane = pane + state.adaptations = pane?.let(::adaptationsIn) ?: emptySet() + state.lastPanedNavigationStateHash = panedNavigationStateHash + } } } } From 37c1c4e4ea86a065025ff9ebbcc6bcb1da35d992 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 08:23:28 -0400 Subject: [PATCH 48/78] Add identity hashcode method --- .../treenav/compose/PaneScope.android.kt | 20 +++++++++++++++ .../com/tunjid/treenav/compose/PaneScope.kt | 6 +++-- .../tunjid/treenav/compose/PaneScope.jvm.kt | 20 +++++++++++++++ .../treenav/compose/PaneScope.native.kt | 25 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/PaneScope.android.kt create mode 100644 library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/PaneScope.jvm.kt create mode 100644 library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/PaneScope.native.kt diff --git a/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/PaneScope.android.kt b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/PaneScope.android.kt new file mode 100644 index 0000000..19e208c --- /dev/null +++ b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/PaneScope.android.kt @@ -0,0 +1,20 @@ +/* + * 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 + +internal actual fun Any.identityHash(): Int = + System.identityHashCode(this) \ 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 ece15aa..4d45849 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 @@ -108,7 +108,7 @@ internal class AnimatedPaneScope private constructor( ) = withPaneAndDestination(slot) { pane, destination -> AnimatedPaneScope( slotPaneState = SlotPaneState( - panedNavigationStateHash = this@paneScope.hashCode(), + panedNavigationStateHash = this@paneScope.identityHash(), slot = slot, currentDestination = destination, previousDestination = previousPanesToDestinations[pane], @@ -126,7 +126,7 @@ internal class AnimatedPaneScope private constructor( ) { withPaneAndDestination(slot) { pane, _ -> val state = animatedPaneScope.slotPaneState - val panedNavigationStateHash = this@update.hashCode() + val panedNavigationStateHash = this@update.identityHash() if (state.slot == slot && state.pane == pane @@ -159,3 +159,5 @@ sealed interface PaneState { */ @JvmInline internal value class Slot internal constructor(val index: Int) + +internal expect fun Any.identityHash(): Int diff --git a/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/PaneScope.jvm.kt b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/PaneScope.jvm.kt new file mode 100644 index 0000000..19e208c --- /dev/null +++ b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/PaneScope.jvm.kt @@ -0,0 +1,20 @@ +/* + * 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 + +internal actual fun Any.identityHash(): Int = + System.identityHashCode(this) \ No newline at end of file diff --git a/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/PaneScope.native.kt b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/PaneScope.native.kt new file mode 100644 index 0000000..fa1a1a8 --- /dev/null +++ b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/PaneScope.native.kt @@ -0,0 +1,25 @@ +/* + * 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 kotlin.experimental.ExperimentalNativeApi +import kotlin.native.identityHashCode + + +@OptIn(ExperimentalNativeApi::class) +internal actual fun Any.identityHash(): Int = + identityHashCode() \ No newline at end of file From 23a5459216d1defd4bad504f7d952dac65de20cb Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 08:27:05 -0400 Subject: [PATCH 49/78] Even more optimizations --- .../com/tunjid/treenav/compose/MultiPaneDisplay.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 9dd9c47..6db70ab 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -319,10 +319,16 @@ private class MultiPaneDisplayScene( LocalPaneScope provides scope ) { with(scope) { - val enterTransition = remember(scope.isPreviewingBack) { + val enterTransition = remember( + scope.isPreviewingBack, + panedNavigationState.identityHash(), + ) { entry.paneEnterTransition(this) } - val exitTransition = remember(scope.isPreviewingBack) { + val exitTransition = remember( + scope.isPreviewingBack, + panedNavigationState.identityHash(), + ) { entry.paneExitTransition(this) } val shouldAnimate = enterTransition != EnterTransition.None From b0faa1cd63af86b5f07ba7e9d689c2fcc677e444 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 10:38:18 -0400 Subject: [PATCH 50/78] Annotate MultiPaneDisplayScene as stable --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 6db70ab..6efec6b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -270,6 +270,7 @@ private class MultiPanePaneSceneStrategy( override val entries: List>, override val previousEntries: List>, From 8cce71f9700006c69a022ca547bd649294ad4443 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 11:02:55 -0400 Subject: [PATCH 51/78] Better names for parameters and fields --- .../treenav/compose/threepane/ThreePane.kt | 26 +++++++------- .../treenav/compose/MultiPaneDisplay.kt | 4 +-- .../treenav/compose/MultiPaneDisplayState.kt | 36 +++++++++---------- .../com/tunjid/treenav/compose/PaneEntry.kt | 15 +++++++- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index ec319af..dd2f218 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -73,32 +73,30 @@ enum class ThreePane { } /** - * 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 - * [ThreePane.TransientPrimary] panes, the pane animations are not run. + * Provides a default [PaneEntry] for selectively running animations in + * [ThreePane] [MultiPaneDisplay]. * - * @param enterTransition the transition to run for the entering pane. - * @param exitTransition the transition to run for the exiting pane. - * @param paneMapping the mapping of panes to navigation destinations. + * @param enterTransition the [EnterTransition] used when this [PaneEntry] adapts in the display. + * @param exitTransition the [ExitTransition] used when this [PaneEntry] adapts in the display. + * @param paneMapping the [Destination]s that are shown alongside the [Destination] provided and + * which of the [ThreePane]s they should show up in. * @param render the Composable for rendering the current destination. */ -fun threePaneEntry( - enterTransition: PaneScope.() -> EnterTransition = { +fun threePaneEntry( + enterTransition: PaneScope.() -> EnterTransition = { if (canAnimate()) DefaultFadeIn else EnterTransition.None }, - exitTransition: PaneScope.() -> ExitTransition = { + exitTransition: PaneScope.() -> ExitTransition = { if (canAnimate()) DefaultFadeOut else ExitTransition.None }, - paneMapping: @Composable (R) -> Map = { + paneMapping: @Composable (Destination) -> Map = { mapOf(ThreePane.Primary to it) }, - render: @Composable (PaneScope.(R) -> Unit), + render: @Composable (PaneScope.(Destination) -> Unit), ) = PaneEntry( enterTransition = enterTransition, exitTransition = exitTransition, - paneTransform = paneMapping, + paneMapping = paneMapping, content = render ) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 6efec6b..d7b88f8 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -113,7 +113,7 @@ fun MultiPaneDisplay( ) { val navigationState by state.navigationState val panesToDestinations = rememberUpdatedState( - state.panesToDestinationsTransform( + state.destinationPanes( state.destinationTransform(navigationState) ) ) @@ -250,7 +250,7 @@ private class MultiPanePaneSceneStrategy internal constructor( internal val panes: List, @@ -52,14 +52,14 @@ class MultiPaneDisplayState in internal val popTransform: (NavigationState) -> NavigationState, internal val onPopped: (NavigationState) -> Unit, internal val transitionSpec: MultiPaneDisplayScope.() -> ContentTransform, - internal val entryProvider: (Destination) -> PaneEntry, - internal val panesToDestinationsTransform: @Composable (Destination) -> Map, - internal val renderTransform: @Composable PaneScope.(PaneEntry, Destination) -> Unit, + internal val paneEntryProvider: (Destination) -> PaneEntry, + internal val destinationPanes: @Composable (Destination) -> Map, + internal val destinationContent: @Composable PaneScope.(PaneEntry, Destination) -> Unit, ) { internal val backPreviewState = mutableStateOf(false) internal val navEntryProvider = { destination: Destination -> - val paneEntry = entryProvider(destination) + val paneEntry = paneEntryProvider(destination) NavEntry( key = destination, contentKey = destination.id, @@ -71,7 +71,7 @@ class MultiPaneDisplayState in PANE_EXIT_TRANSITION_KEY to paneEntry.exitTransition, ), content = { innerDestination -> - renderTransform(localPaneScope(), paneEntry, innerDestination) + destinationContent(localPaneScope(), paneEntry, innerDestination) }, ) } @@ -141,11 +141,11 @@ fun MultiPaneDisplayState( popTransform = popTransform, onPopped = onPopped, transitionSpec = transitionSpec, - entryProvider = entryProvider, - panesToDestinationsTransform = { destination -> - entryProvider(destination).paneTransform(destination) + paneEntryProvider = entryProvider, + destinationPanes = { destination -> + entryProvider(destination).paneMapping(destination) }, - renderTransform = transform@{ paneEntry, destination -> + destinationContent = transform@{ paneEntry, destination -> paneEntry.content(this@transform, destination) } ), @@ -164,24 +164,24 @@ private operator fun onPopped = onPopped, destinationTransform = destinationTransform, transitionSpec = transitionSpec, - entryProvider = entryProvider, - panesToDestinationsTransform = when (transform) { + paneEntryProvider = paneEntryProvider, + destinationPanes = when (transform) { is PaneTransform -> { destination -> transform.toPanesAndDestinations( destination = destination, - previousTransform = panesToDestinationsTransform, + previousTransform = destinationPanes, ) } - else -> panesToDestinationsTransform + else -> destinationPanes }, - renderTransform = when (transform) { + destinationContent = when (transform) { is RenderTransform -> { paneEntry, destination -> with(transform) { Render( destination = destination, previousTransform = previous@{ innerDestination -> - renderTransform( + destinationContent( this@previous, paneEntry, innerDestination, @@ -191,7 +191,7 @@ private operator fun } } - else -> renderTransform + else -> destinationContent }, ) 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 index bf0c241..172d161 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt @@ -12,8 +12,21 @@ import com.tunjid.treenav.Node */ @Stable class PaneEntry( + /** + * The [EnterTransition] used when this [PaneEntry] adapts to its current [Pane]. + */ internal val enterTransition: PaneScope.() -> EnterTransition, + /** + * The [ExitTransition] used when this [PaneEntry] adapts to its current [Pane]. + */ internal val exitTransition: PaneScope.() -> ExitTransition, - internal val paneTransform: @Composable (Destination) -> Map, + /** + * Provides the [Destination]s that are shown alongside the [Destination] provided and + * what [Pane]s they should show up in. + */ + internal val paneMapping: @Composable (Destination) -> Map, + /** + * Provides the content to show for the given [Destination]. + */ internal val content: @Composable PaneScope.(Destination) -> Unit, ) \ No newline at end of file From 38d4fe35efcbfe92f3a20acefd75dbf3b061c497 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 11:04:45 -0400 Subject: [PATCH 52/78] Clean up redundant static fields --- .../tunjid/treenav/compose/threepane/ThreePane.kt | 12 ------------ .../transforms/MovableSharedElementTransform.kt | 10 +++++++++- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index dd2f218..8dcfd52 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -58,18 +58,6 @@ enum class ThreePane { * An optional pane for showing dialogs, or context sheets over existing panes. */ Overlay; - - companion object { - val PrimaryToSecondary = Swap( - from = Primary, - to = Secondary - ) - - val SecondaryToPrimary = Swap( - from = Secondary, - to = Primary - ) - } } /** diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 3f89f8b..7ef37e8 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layout import com.tunjid.treenav.Node import com.tunjid.treenav.compose.Adaptation +import com.tunjid.treenav.compose.Adaptation.Swap import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.PaneScope @@ -40,6 +41,8 @@ import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScop import com.tunjid.treenav.compose.moveablesharedelement.PaneMovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.rememberPaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.threepane.ThreePane.Primary +import com.tunjid.treenav.compose.threepane.ThreePane.Secondary import com.tunjid.treenav.compose.transforms.RenderTransform import com.tunjid.treenav.compose.transforms.Transform @@ -188,12 +191,17 @@ private class ThreePaneMovableSharedElementScope( private fun PaneScope.canAnimateSecondary(): Boolean { if (inPredictiveBack) return false - if (!paneState.adaptations.contains(ThreePane.PrimaryToSecondary)) return false + if (!paneState.adaptations.contains(PrimaryToSecondary)) return false if (paneState.adaptations.contains(Adaptation.Pop)) return false return true } +private val PrimaryToSecondary = Swap( + from = Primary, + to = Secondary +) + private fun Modifier.fillMaxConstraints() = layout { measurable, constraints -> val placeable = measurable.measure( From d5c2b7d5b274a9dd407584def65334b9490f46ef Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 11:05:32 -0400 Subject: [PATCH 53/78] Remove unused import --- .../threepane/transforms/MovableSharedElementTransform.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 7ef37e8..93e2dd0 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -41,8 +41,6 @@ import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScop import com.tunjid.treenav.compose.moveablesharedelement.PaneMovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.rememberPaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.ThreePane.Primary -import com.tunjid.treenav.compose.threepane.ThreePane.Secondary import com.tunjid.treenav.compose.transforms.RenderTransform import com.tunjid.treenav.compose.transforms.Transform @@ -198,8 +196,8 @@ private fun PaneScope.canAnimateSecondary(): Boolean { } private val PrimaryToSecondary = Swap( - from = Primary, - to = Secondary + from = ThreePane.Primary, + to = ThreePane.Secondary ) private fun Modifier.fillMaxConstraints() = From c261501c79300479dd5e6841f9d17a1d02d346d7 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 11:19:53 -0400 Subject: [PATCH 54/78] Update docs --- .../kotlin/com/tunjid/treenav/compose/PaneScope.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 4d45849..f5f0ea1 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 @@ -19,6 +19,7 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Transition import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -41,12 +42,13 @@ interface PaneScope : AnimatedVisibilityScope { val paneState: PaneState /** - * Whether or not this [PaneScope] is active in its current pane. It is active when - * the current navigation destination is rendering this pane. + * Whether or not this [PaneScope] is active in its current pane. It is active when this pane + * matches the current navigation destination or any of its co-displayed panes in a given + * scene. * * This means that during predictive back animations, the outgoing panes, i.e the panes - * whose [AnimatedVisibilityScope.transition] report [EnterExitState.Visible] are considered - * active. + * whose [AnimatedVisibilityScope.transition] have their [Transition.targetState] + * NOT reporting [EnterExitState.Visible] are considered active. */ val isActive: Boolean From 2f5cf8921439194269a3afd56009230f44a2f6b1 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 12:14:33 -0400 Subject: [PATCH 55/78] Update stale animation --- .../treenav/compose/MultiPaneDisplay.kt | 33 +++++++++---------- .../com/tunjid/treenav/compose/PaneScope.kt | 14 ++++++-- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index d7b88f8..e7f7c4b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -312,6 +312,7 @@ private class MultiPaneDisplayScene( }.also { panedNavigationState.update( animatedPaneScope = it, + animatedContentScope = animatedContentScope, slot = slot, ) } @@ -320,27 +321,25 @@ private class MultiPaneDisplayScene( LocalPaneScope provides scope ) { with(scope) { - val enterTransition = remember( - scope.isPreviewingBack, + val paneModifier = remember( + isActive, + inPredictiveBack, panedNavigationState.identityHash(), ) { - entry.paneEnterTransition(this) + val enterTransition = entry.paneEnterTransition(this) + val exitTransition = entry.paneExitTransition(this) + val shouldAnimate = enterTransition != EnterTransition.None + || exitTransition != ExitTransition.None + + if (shouldAnimate) Modifier.animateEnterExit( + enterTransition, + exitTransition + ) + else Modifier } - val exitTransition = remember( - scope.isPreviewingBack, - panedNavigationState.identityHash(), - ) { - entry.paneExitTransition(this) - } - val shouldAnimate = enterTransition != EnterTransition.None - || exitTransition != ExitTransition.None + Box( - modifier = - if (shouldAnimate) Modifier.animateEnterExit( - enterTransition, - exitTransition - ) - else Modifier, + modifier = paneModifier, content = { entry.Content() } 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 f5f0ea1..0a737f9 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 @@ -65,8 +65,10 @@ interface PaneScope : AnimatedVisibilityScope { internal class AnimatedPaneScope private constructor( private val slotPaneState: SlotPaneState, val isPreviewingBack: () -> Boolean, - val animatedContentScope: AnimatedContentScope -) : PaneScope, AnimatedVisibilityScope by animatedContentScope { + animatedContentScope: AnimatedContentScope +) : PaneScope, AnimatedVisibilityScope { + + var animatedContentScope: AnimatedContentScope by mutableStateOf(animatedContentScope) override val paneState: PaneState get() = slotPaneState @@ -80,6 +82,9 @@ internal class AnimatedPaneScope private constructor( override val inPredictiveBack: Boolean get() = isPreviewingBack() + override val transition: Transition + get() = animatedContentScope.transition + companion object { /** * [Slot] based implementation of [PaneState] @@ -94,6 +99,8 @@ internal class AnimatedPaneScope private constructor( adaptations: Set, ) : PaneState { var slot: Slot? by mutableStateOf(slot) + + @Suppress("unused") val previousDestination: Destination? by mutableStateOf(previousDestination) override val currentDestination: Destination? by mutableStateOf(currentDestination) @@ -124,8 +131,11 @@ internal class AnimatedPaneScope private constructor( fun SlotBasedPanedNavigationState.update( animatedPaneScope: AnimatedPaneScope, + animatedContentScope: AnimatedContentScope, slot: Slot, ) { + animatedPaneScope.animatedContentScope = animatedContentScope + withPaneAndDestination(slot) { pane, _ -> val state = animatedPaneScope.slotPaneState val panedNavigationStateHash = this@update.identityHash() From e147d916cdd12f114b86b1e4f64f439921de7111 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 14:29:20 -0400 Subject: [PATCH 56/78] Fix DragToPop and back previews. Expose NavigationEvent APIs --- ...ansitionAwareLifecycleNavEntryDecorator.kt | 99 ------------------- .../navigation3/decorators/Utilities.kt | 19 ---- .../ui/LocalNavAnimatedContentScope.kt | 4 +- .../ui/LocalNavigationEventDispatcherOwner.kt | 2 +- .../navigation3/ui/NavigationEventHandler.kt | 2 +- sample/common/build.gradle.kts | 2 + .../com/tunjid/demo/common/ui/DemoApp.kt | 52 ++++++---- .../com/tunjid/demo/common/ui/DragToPop.kt | 69 +++++++++---- .../tunjid/demo/common/ui/PredictiveBack.kt | 6 +- 9 files changed, 97 insertions(+), 158 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/Utilities.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt deleted file mode 100644 index 5357e4f..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt +++ /dev/null @@ -1,99 +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.navigation3.decorators - - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator - -@Composable -internal fun transitionAwareLifecycleNavEntryDecorator( - backStack: List, - isSettled: @Composable () -> Boolean -) = navEntryDecorator { entry -> - val isInBackStack = entry.isInBackStack(backStack) - val settled = isSettled() - val maxLifecycle = - when { - isInBackStack && settled -> Lifecycle.State.RESUMED - isInBackStack && !settled -> Lifecycle.State.STARTED - else /* !isInBackStack */ -> Lifecycle.State.CREATED - } - LifecycleOwner(maxLifecycle = maxLifecycle) { entry.Content() } -} - -@Composable -private fun LifecycleOwner( - maxLifecycle: Lifecycle.State = Lifecycle.State.RESUMED, - parentLifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, - content: @Composable () -> Unit, -) { - val childLifecycleOwner = remember(parentLifecycleOwner) { ChildLifecycleOwner() } - // Pass LifecycleEvents from the parent down to the child - DisposableEffect(childLifecycleOwner, parentLifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - childLifecycleOwner.handleLifecycleEvent(event) - } - - parentLifecycleOwner.lifecycle.addObserver(observer) - - onDispose { parentLifecycleOwner.lifecycle.removeObserver(observer) } - } - // Ensure that the child lifecycle is capped at the maxLifecycle - LaunchedEffect(childLifecycleOwner, maxLifecycle) { - childLifecycleOwner.maxLifecycle = maxLifecycle - } - // Now install the LifecycleOwner as a composition local - CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) { content.invoke() } -} - -private class ChildLifecycleOwner : LifecycleOwner { - private val lifecycleRegistry = LifecycleRegistry(this) - - override val lifecycle: Lifecycle - get() = lifecycleRegistry - - var maxLifecycle: Lifecycle.State = Lifecycle.State.INITIALIZED - set(maxState) { - field = maxState - updateState() - } - - private var parentLifecycleState: Lifecycle.State = Lifecycle.State.CREATED - - fun handleLifecycleEvent(event: Lifecycle.Event) { - parentLifecycleState = event.targetState - updateState() - } - - fun updateState() { - if (parentLifecycleState.ordinal < maxLifecycle.ordinal) { - lifecycleRegistry.currentState = parentLifecycleState - } else { - lifecycleRegistry.currentState = maxLifecycle - } - } -} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/Utilities.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/Utilities.kt deleted file mode 100644 index 318a24f..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/Utilities.kt +++ /dev/null @@ -1,19 +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.navigation3.decorators - -internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt index 92bac38..7903dc3 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt @@ -26,9 +26,9 @@ import androidx.compose.runtime.compositionLocalOf * This does not have a default value since the AnimatedContentScope is provided at runtime by * AnimatedContent. * - * @sample androidx.navigation3.ui.samples.SceneNavSharedElementSample + * @sample androidx.navigation3.ui.samples.SceneNavSharedElementSampleo */ -public val LocalNavAnimatedContentScope: ProvidableCompositionLocal = +internal val LocalNavAnimatedContentScope: ProvidableCompositionLocal = compositionLocalOf { // no default, we need an AnimatedContent to get the AnimatedContentScope throw IllegalStateException( diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt index 4332b90..cf27511 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt @@ -23,7 +23,7 @@ import androidx.navigationevent.NavigationEventDispatcher import androidx.navigationevent.NavigationEventDispatcherOwner /** The CompositionLocal containing the current [NavigationEventDispatcher]. */ -internal object LocalNavigationEventDispatcherOwner { +object LocalNavigationEventDispatcherOwner { private val LocalNavigationEventDispatcherOwner = compositionLocalOf { null } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt index b2babee..ca4c0d2 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch @Composable -internal fun NavigationEventHandler( +fun NavigationEventHandler( enabled: () -> Boolean = { true }, passThrough: Boolean = false, onBack: suspend (progress: Flow) -> Unit, diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts index 3e450ed..eb8aaf6 100755 --- a/sample/common/build.gradle.kts +++ b/sample/common/build.gradle.kts @@ -32,6 +32,8 @@ kotlin { implementation(compose.components.resources) + implementation(libs.androidx.navigation.event) + implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.animation) implementation(libs.jetbrains.compose.material3) 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 9416793..2cff786 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 @@ -58,11 +58,15 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.navigationevent.NavigationEvent 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.rememberMultiPaneDisplayState @@ -79,10 +83,12 @@ import com.tunjid.treenav.compose.MultiPaneDisplayScope import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.multiPaneDisplayBackstack +import com.tunjid.treenav.compose.navigation3.ui.NavigationEventHandler import com.tunjid.treenav.compose.threepane.ThreePane 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.pop import com.tunjid.treenav.popToRoot import com.tunjid.treenav.requireCurrent @@ -122,22 +128,20 @@ fun App( appState.splitLayoutState.size } ), -// backPreviewTransform( -// isPreviewingBack = derivedStateOf { -// appState.isPreviewingBack -// }, -// navigationStateBackTransform = MultiStackNav::pop, -// ), threePanedMovableSharedElementTransform( movableSharedElementHostState = movableSharedElementHostState ), -// paneModifierTransform { -// if (paneState.pane == ThreePane.TransientPrimary) Modifier -// .fillMaxSize() -// .backPreview(appState.backPreviewState) -// else Modifier -// .fillMaxSize() -// }, + paneModifierTransform { + if (paneState.pane == ThreePane.Primary + && inPredictiveBack + && isActive + && !appState.dragToPopState.isDraggingToPop + ) Modifier + .fillMaxSize() + .backPreview(appState.backPreviewState) + else Modifier + .fillMaxSize() + }, ) }, ), @@ -164,6 +168,24 @@ fun App( } ) } + + NavigationEventHandler( + enabled = { true }, + passThrough = true, + ) { progress -> + try { + progress.collect { event -> + appState.backPreviewState.progress = event.progress + appState.backPreviewState.atStart = + event.swipeEdge == NavigationEvent.EDGE_LEFT + appState.backPreviewState.pointerOffset = + Offset(event.touchX, event.touchY).round() + } + appState.backPreviewState.progress = 0f + } finally { + appState.backPreviewState.progress = 0f + } + } } } } @@ -259,10 +281,6 @@ class AppState( ) internal val dragToPopState = DragToPopState() - internal val isPreviewingBack - get() = !backPreviewState.progress.isNaN() - || dragToPopState.isDraggingToPop - internal val isMediumScreenWidthOrWider get() = splitLayoutState.size >= SecondaryPaneMinWidthBreakpointDp internal var displayScope by mutableStateOf?>( diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt index 430ce12..4607fa2 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt @@ -16,26 +16,28 @@ package com.tunjid.demo.common.ui -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset 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.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round +import androidx.navigationevent.NavigationEvent import com.tunjid.composables.dragtodismiss.DragToDismissState import com.tunjid.composables.dragtodismiss.dragToDismiss -import com.tunjid.demo.common.ui.data.SampleDestination -import com.tunjid.treenav.compose.MultiPaneDisplayScope -import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.navigation3.ui.LocalNavigationEventDispatcherOwner +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.min @Stable internal class DragToPopState { @@ -47,27 +49,46 @@ internal class DragToPopState { @Composable fun Modifier.dragToPop(): Modifier { - val state = LocalAppState.current.dragToPopState - DisposableEffect(state) { - state.dragToDismissState.enabled = true - onDispose { state.dragToDismissState.enabled = false } + val state = LocalAppState.current + val dragToPopState = state.dragToPopState + + val density = LocalDensity.current + val dismissThreshold = remember { with(density) { 200.dp.toPx().let { it * it } } } + + val dispatcher = checkNotNull( + LocalNavigationEventDispatcherOwner.current?.navigationEventDispatcher + ) + LaunchedEffect(Unit) { + snapshotFlow { + state.dragToPopState.dragToDismissState.offset + } + .collectLatest { + if (state.dragToPopState.isDraggingToPop) { + dispatcher.dispatchOnProgressed( + state.dragToPopState.dragToDismissState.navigationEvent( + min( + a = 1f, + b = state.dragToPopState.dragToDismissState.offset.getDistanceSquared() / dismissThreshold, + ) + ) + ) + } + } + } + + DisposableEffect(dragToPopState) { + dragToPopState.dragToDismissState.enabled = true + onDispose { dragToPopState.dragToDismissState.enabled = false } } // TODO: This should not be necessary. Figure out why a frame renders with // an offset of zero while the content in the transient primary container // is still visible. val dragToDismissOffset by rememberUpdatedStateIf( - value = state.dragToDismissState.offset.round(), + value = dragToPopState.dragToDismissState.offset.round(), predicate = { it != IntOffset.Zero } ) - return offset { dragToDismissOffset } -} - -@Composable -private fun Modifier.dragToPopInternal(state: AppState): Modifier { - val density = LocalDensity.current - val dismissThreshold = remember { with(density) { 200.dp.toPx().let { it * it } } } return dragToDismiss( state = state.dragToPopState.dragToDismissState, @@ -77,21 +98,35 @@ private fun Modifier.dragToPopInternal(state: AppState): Modifier { // Enable back preview onStart = { state.dragToPopState.isDraggingToPop = true + dispatcher.dispatchOnStarted( + state.dragToPopState.dragToDismissState.navigationEvent(0f) + ) }, onCancelled = { // Dismiss back preview state.dragToPopState.isDraggingToPop = false + dispatcher.dispatchOnCancelled() }, onDismissed = { // Dismiss back preview state.dragToPopState.isDraggingToPop = false // Pop navigation - state.goBack() + dispatcher.dispatchOnCompleted() } ) + .offset { dragToDismissOffset } } +private fun DragToDismissState.navigationEvent( + progress: Float +) = NavigationEvent( + touchX = offset.x, + touchY = offset.y, + progress = progress, + swipeEdge = NavigationEvent.EDGE_LEFT, +) + @Composable private inline fun rememberUpdatedStateIf( value: T, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt index f836305..19741f0 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt @@ -39,8 +39,10 @@ import com.tunjid.treenav.compose.threepane.ThreePane fun Modifier.predictiveBackBackgroundModifier( paneScope: PaneScope, ): Modifier { -// if (paneScope.paneState.pane != ThreePane.TransientPrimary) - return this + if (paneScope.paneState.pane != ThreePane.Primary + || !paneScope.isActive + || !paneScope.inPredictiveBack + ) return this var elevation by remember { mutableStateOf(0.dp) } LaunchedEffect(Unit) { From fb11d29b5265a044a3c3578a0ccbe140a62800e7 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 21:50:03 -0400 Subject: [PATCH 57/78] Mark MultipaneDisplayState as stable --- .../com/tunjid/treenav/compose/MultiPaneDisplayState.kt | 2 ++ libraryVersion.properties | 8 ++++---- .../kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 5d0da0d..6248130 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -20,6 +20,7 @@ import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import com.tunjid.treenav.Node @@ -44,6 +45,7 @@ import com.tunjid.treenav.compose.transforms.Transform * [Destination] to the panes available. * @param destinationContent the transform used to render a [Destination] in its pane. */ +@Stable class MultiPaneDisplayState internal constructor( internal val panes: List, internal val navigationState: State, diff --git a/libraryVersion.properties b/libraryVersion.properties index ae8a36e..56ac59b 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,7 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.27 -strings_version=0.0.27 -compose_version=0.0.27 -compose-threepane_version=0.0.27 \ No newline at end of file +treenav_version=0.0.30a +strings_version=0.0.30a +compose_version=0.0.30a +compose-threepane_version=0.0.30a \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt index 99042dd..06b4a7a 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.NavigationRailItem import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.treenav.compose.Adaptation import com.tunjid.treenav.current @Composable @@ -77,7 +78,9 @@ fun PaneScaffoldState.PaneNavigationRail( boundsTransform = NavigationRailBoundsTransform, ), visible = canShowNavigationRail, - enter = if (canShowNavigationRail) enterTransition else EnterTransition.None, + enter = if (canShowNavigationRail + && paneState.adaptations.none { it is Adaptation.Swap<*> } + ) enterTransition else EnterTransition.None, exit = if (canShowNavigationRail) exitTransition else ExitTransition.None, ) { val appState = LocalAppState.current From 65cb57708d3de141262d412bb63e75ea6fe123ac Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 24 Jun 2025 21:55:35 -0400 Subject: [PATCH 58/78] More optimizations --- .../treenav/compose/MultiPaneDisplay.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index e7f7c4b..146406b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -58,7 +58,6 @@ import com.tunjid.treenav.compose.navigation3.ui.Scene import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.collect /** * Scope that provides context about individual panes [Pane] in an [MultiPaneDisplay]. @@ -160,10 +159,12 @@ fun MultiPaneDisplay( ) } - val transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = { - state.transitionSpec( - sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope - ) + val transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = remember { + { + state.transitionSpec( + sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope + ) + } } NavDisplay( @@ -192,12 +193,13 @@ fun MultiPaneDisplay( ) NavigationEventHandler( - enabled = { true }, + enabled = AlwaysTrue, passThrough = true, ) { progress -> try { - state.backPreviewState.value = true - progress.collect() + progress.collect { + state.backPreviewState.value = true + } state.backPreviewState.value = false } catch (e: CancellationException) { state.backPreviewState.value = false @@ -404,6 +406,8 @@ private fun SlotBasedPanedNavigationState.sceneDestinationKey: String get() { val target = targetState as Pair<*, *> From 70f3697dccea5ec1b8ad7f562874d024895ed5e2 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Wed, 25 Jun 2025 09:13:12 -0400 Subject: [PATCH 59/78] Update definition of isActive --- .../treenav/compose/MultiPaneDisplay.kt | 11 ++-- .../com/tunjid/treenav/compose/PaneScope.kt | 58 +++++++++++-------- libraryVersion.properties | 8 +-- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 146406b..4526e8e 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -266,7 +266,7 @@ private class MultiPanePaneSceneStrategy( slot = slot, isPreviewingBack = isPreviewingBack, animatedContentScope = animatedContentScope, + isInCurrentDestination = destination.id == currentPanedNavigationState.backStackIds.last() ) - }.also { + }.also { scope -> panedNavigationState.update( - animatedPaneScope = it, - animatedContentScope = animatedContentScope, slot = slot, + animatedPaneScope = scope, + animatedContentScope = animatedContentScope, + isInCurrentDestination = destination.id == currentPanedNavigationState.backStackIds.last() ) } @@ -327,6 +329,7 @@ private class MultiPaneDisplayScene( isActive, inPredictiveBack, panedNavigationState.identityHash(), + animatedContentScope.transition.targetState, ) { val enterTransition = entry.paneEnterTransition(this) val exitTransition = entry.paneExitTransition(this) 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 0a737f9..347c446 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 @@ -21,7 +21,6 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState import androidx.compose.animation.core.Transition import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -65,20 +64,23 @@ interface PaneScope : AnimatedVisibilityScope { internal class AnimatedPaneScope private constructor( private val slotPaneState: SlotPaneState, val isPreviewingBack: () -> Boolean, + isInCurrentDestination: Boolean, animatedContentScope: AnimatedContentScope ) : PaneScope, AnimatedVisibilityScope { - var animatedContentScope: AnimatedContentScope by mutableStateOf(animatedContentScope) + private var animatedContentScope: AnimatedContentScope by mutableStateOf(animatedContentScope) + + private var isInCurrentDestination by mutableStateOf(isInCurrentDestination) + + private val isEntering + get() = animatedContentScope.transition.targetState == EnterExitState.Visible + + override val isActive: Boolean + get() = if (inPredictiveBack) isInCurrentDestination else isEntering override val paneState: PaneState get() = slotPaneState - override val isActive: Boolean by derivedStateOf { - val isEntering = animatedContentScope.transition.targetState == EnterExitState.Visible - if (inPredictiveBack) !isEntering - else isEntering - } - override val inPredictiveBack: Boolean get() = isPreviewingBack() @@ -112,6 +114,7 @@ internal class AnimatedPaneScope private constructor( fun SlotBasedPanedNavigationState.paneScope( slot: Slot, + isInCurrentDestination: Boolean, isPreviewingBack: () -> Boolean, animatedContentScope: AnimatedContentScope ) = withPaneAndDestination(slot) { pane, destination -> @@ -124,6 +127,7 @@ internal class AnimatedPaneScope private constructor( pane = pane, adaptations = pane?.let(::adaptationsIn) ?: emptySet(), ), + isInCurrentDestination = isInCurrentDestination, isPreviewingBack = isPreviewingBack, animatedContentScope = animatedContentScope ) @@ -132,25 +136,31 @@ internal class AnimatedPaneScope private constructor( fun SlotBasedPanedNavigationState.update( animatedPaneScope: AnimatedPaneScope, animatedContentScope: AnimatedContentScope, + isInCurrentDestination: Boolean, slot: Slot, - ) { - animatedPaneScope.animatedContentScope = animatedContentScope - - withPaneAndDestination(slot) { pane, _ -> - val state = animatedPaneScope.slotPaneState - val panedNavigationStateHash = this@update.identityHash() - - if (state.slot == slot - && state.pane == pane - && state.lastPanedNavigationStateHash == panedNavigationStateHash - ) return@withPaneAndDestination - + ) = withPaneAndDestination(slot) { pane, _ -> + val state = animatedPaneScope.slotPaneState + val panedNavigationStateHash = this@update.identityHash() + + if (state.slot == slot + && state.pane == pane + && state.lastPanedNavigationStateHash == panedNavigationStateHash + ) { Snapshot.withMutableSnapshot { - state.slot = slot - state.pane = pane - state.adaptations = pane?.let(::adaptationsIn) ?: emptySet() - state.lastPanedNavigationStateHash = panedNavigationStateHash + animatedPaneScope.animatedContentScope = animatedContentScope + animatedPaneScope.isInCurrentDestination = isInCurrentDestination } + return@withPaneAndDestination + } + + Snapshot.withMutableSnapshot { + animatedPaneScope.animatedContentScope = animatedContentScope + animatedPaneScope.isInCurrentDestination = isInCurrentDestination + + state.slot = slot + state.pane = pane + state.adaptations = pane?.let(::adaptationsIn) ?: emptySet() + state.lastPanedNavigationStateHash = panedNavigationStateHash } } } diff --git a/libraryVersion.properties b/libraryVersion.properties index 56ac59b..5bcb90a 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,7 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.30a -strings_version=0.0.30a -compose_version=0.0.30a -compose-threepane_version=0.0.30a \ No newline at end of file +treenav_version=0.0.30d +strings_version=0.0.30d +compose_version=0.0.30d +compose-threepane_version=0.0.30d \ No newline at end of file From 3dc690da65ae8669f423fff681693ea87114e85e Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 27 Jun 2025 13:01:32 -0400 Subject: [PATCH 60/78] Revert AnimatedPaneScope immutability changes. Try to identify lagging transitions --- .../treenav/compose/MultiPaneDisplay.kt | 62 ++++++--- .../com/tunjid/treenav/compose/PaneScope.kt | 130 +++++------------- .../compose/SlotBasedPanedNavigationState.kt | 16 ++- 3 files changed, 86 insertions(+), 122 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 4526e8e..e5503bf 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -21,12 +21,14 @@ import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Transition import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -42,8 +44,6 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.AnimatedPaneScope.Companion.paneScope -import com.tunjid.treenav.compose.AnimatedPaneScope.Companion.update import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.children import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.id import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.paneEnterTransition @@ -58,6 +58,7 @@ import com.tunjid.treenav.compose.navigation3.ui.Scene import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator import kotlinx.coroutines.CancellationException +import kotlin.jvm.JvmInline /** * Scope that provides context about individual panes [Pane] in an [MultiPaneDisplay]. @@ -216,7 +217,7 @@ private class MultiPanePaneSceneStrategy.() -> Unit), ) : SceneStrategy { - val scenes = mutableMapOf>() + val scenes = mutableMapOf>() @Composable override fun calculateScene( @@ -247,13 +248,16 @@ private class MultiPanePaneSceneStrategy( + private val sceneKey: MultiPaneSceneKey, override val entries: List>, override val previousEntries: List>, private val backstackIds: List, @@ -301,24 +306,26 @@ private class MultiPaneDisplayScene( override fun Destination(pane: Pane) { val id = panedNavigationState.destinationFor(pane)?.id val entry = entries.firstOrNull { it.id == id } ?: return - val slot = panedNavigationState.slotFor(pane) ?: return + + val paneState = remember(panedNavigationState.identityHash()) { + panedNavigationState.slotFor(pane)?.let(panedNavigationState::paneStateFor) + } ?: return val animatedContentScope = LocalNavAnimatedContentScope.current val scope = remember { - panedNavigationState.paneScope( - slot = slot, + AnimatedPaneScope( isPreviewingBack = isPreviewingBack, + paneState = paneState, + activeState = derivedStateOf { + val targetSceneKey = + animatedContentScope.transition.sceneTargetDestinationKey + sceneKey == targetSceneKey + }, animatedContentScope = animatedContentScope, - isInCurrentDestination = destination.id == currentPanedNavigationState.backStackIds.last() - ) - }.also { scope -> - panedNavigationState.update( - slot = slot, - animatedPaneScope = scope, - animatedContentScope = animatedContentScope, - isInCurrentDestination = destination.id == currentPanedNavigationState.backStackIds.last() ) + }.also { + it.paneState = paneState } CompositionLocalProvider( @@ -360,7 +367,7 @@ private class MultiPaneDisplayScene( panedNavigationState.destinationFor(pane) } - override val key: Any = destination.id + override val key: Any = sceneKey override val content: @Composable () -> Unit = { @@ -409,12 +416,29 @@ private fun SlotBasedPanedNavigationState, +) + private val AlwaysTrue = { true } -private val AnimatedContentTransitionScope<*>.sceneDestinationKey: String +private val AnimatedContentTransitionScope<*>.sceneDestinationKey: MultiPaneSceneKey get() { val target = targetState as Pair<*, *> - return target.second as String + return target.second as MultiPaneSceneKey + } + +internal val Transition<*>.sceneTargetDestinationKey: MultiPaneSceneKey? + get() { + val target = parentTransition?.targetState as? Pair<*, *> ?: return null + return target.second as MultiPaneSceneKey + } + +internal val Transition<*>.sceneCurrentDestinationKey: MultiPaneSceneKey? + get() { + val target = parentTransition?.currentState as? Pair<*, *> ?: return null + return target.second as MultiPaneSceneKey } internal val LocalPaneScope = staticCompositionLocalOf> { 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 347c446..4e00f75 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 @@ -21,11 +21,10 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState import androidx.compose.animation.core.Transition import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot import com.tunjid.treenav.Node import kotlin.jvm.JvmInline @@ -61,109 +60,35 @@ interface PaneScope : AnimatedVisibilityScope { * An implementation of [PaneScope] that supports animations and shared elements */ @Stable -internal class AnimatedPaneScope private constructor( - private val slotPaneState: SlotPaneState, +internal class AnimatedPaneScope( val isPreviewingBack: () -> Boolean, - isInCurrentDestination: Boolean, - animatedContentScope: AnimatedContentScope -) : PaneScope, AnimatedVisibilityScope { - - private var animatedContentScope: AnimatedContentScope by mutableStateOf(animatedContentScope) - - private var isInCurrentDestination by mutableStateOf(isInCurrentDestination) + val activeState: State, + paneState: PaneState, + animatedContentScope: AnimatedContentScope, +) : PaneScope, AnimatedVisibilityScope by animatedContentScope { private val isEntering - get() = animatedContentScope.transition.targetState == EnterExitState.Visible - - override val isActive: Boolean - get() = if (inPredictiveBack) isInCurrentDestination else isEntering - - override val paneState: PaneState - get() = slotPaneState - - override val inPredictiveBack: Boolean - get() = isPreviewingBack() - - override val transition: Transition - get() = animatedContentScope.transition - - companion object { - /** - * [Slot] based implementation of [PaneState] - */ - @Stable - private class SlotPaneState( - panedNavigationStateHash: Int, - slot: Slot?, - previousDestination: Destination?, - currentDestination: Destination?, - pane: Pane?, - adaptations: Set, - ) : PaneState { - var slot: Slot? by mutableStateOf(slot) - - @Suppress("unused") - val previousDestination: Destination? by mutableStateOf(previousDestination) + get() = transition.targetState == EnterExitState.Visible - override val currentDestination: Destination? by mutableStateOf(currentDestination) - override var pane: Pane? by mutableStateOf(pane) - override var adaptations: Set by mutableStateOf(adaptations) + override var paneState by mutableStateOf(paneState) - var lastPanedNavigationStateHash by mutableIntStateOf(panedNavigationStateHash) - } - - fun SlotBasedPanedNavigationState.paneScope( - slot: Slot, - isInCurrentDestination: Boolean, - isPreviewingBack: () -> Boolean, - animatedContentScope: AnimatedContentScope - ) = withPaneAndDestination(slot) { pane, destination -> - AnimatedPaneScope( - slotPaneState = SlotPaneState( - panedNavigationStateHash = this@paneScope.identityHash(), - slot = slot, - currentDestination = destination, - previousDestination = previousPanesToDestinations[pane], - pane = pane, - adaptations = pane?.let(::adaptationsIn) ?: emptySet(), - ), - isInCurrentDestination = isInCurrentDestination, - isPreviewingBack = isPreviewingBack, - animatedContentScope = animatedContentScope - ) + override val isActive: Boolean + get() = when { + inPredictiveBack -> !activeState.value + // Transition lagging predictive back for the enter state + activeState.value && !isEntering -> true + // Transition lagging predictive back for the exit state + !activeState.value && isEntering -> false + // Stabilized, the transition is the source of truth + else -> activeState.value } - fun SlotBasedPanedNavigationState.update( - animatedPaneScope: AnimatedPaneScope, - animatedContentScope: AnimatedContentScope, - isInCurrentDestination: Boolean, - slot: Slot, - ) = withPaneAndDestination(slot) { pane, _ -> - val state = animatedPaneScope.slotPaneState - val panedNavigationStateHash = this@update.identityHash() - - if (state.slot == slot - && state.pane == pane - && state.lastPanedNavigationStateHash == panedNavigationStateHash - ) { - Snapshot.withMutableSnapshot { - animatedPaneScope.animatedContentScope = animatedContentScope - animatedPaneScope.isInCurrentDestination = isInCurrentDestination - } - return@withPaneAndDestination - } - - Snapshot.withMutableSnapshot { - animatedPaneScope.animatedContentScope = animatedContentScope - animatedPaneScope.isInCurrentDestination = isInCurrentDestination - - state.slot = slot - state.pane = pane - state.adaptations = pane?.let(::adaptationsIn) ?: emptySet() - state.lastPanedNavigationStateHash = panedNavigationStateHash - } + override val inPredictiveBack: Boolean + get() { + val currentSize = transition.sceneCurrentDestinationKey?.ids?.size ?: 0 + val targetSize = transition.sceneTargetDestinationKey?.ids?.size ?: 0 + return isPreviewingBack() && (targetSize < currentSize) } - } } /** @@ -176,6 +101,17 @@ sealed interface PaneState { val adaptations: Set } +/** + * [Slot] based implementation of [PaneState] + */ +internal data class SlotPaneState( + val slot: Slot?, + val previousDestination: Destination?, + override val currentDestination: Destination?, + override val pane: Pane?, + override val adaptations: Set, +) : PaneState + /** * A spot taken by an [PaneEntry] that may be moved in from pane to pane. */ 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 737af33..8fe8d94 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 @@ -70,14 +70,18 @@ internal data class SlotBasedPanedNavigationState( ) } - internal inline fun withPaneAndDestination( + internal fun paneStateFor( slot: Slot, - crossinline block: - SlotBasedPanedNavigationState.(pane: Pane?, destination: Destination?) -> T - ): T { + ): PaneState { val node = destinationFor(slot) val pane = node?.let(::paneFor) - return block(pane, node) + return SlotPaneState( + slot = slot, + currentDestination = node, + previousDestination = previousPanesToDestinations[pane], + pane = pane, + adaptations = pane?.let(::adaptationsIn) ?: emptySet(), + ) } internal fun slotFor( @@ -187,7 +191,7 @@ internal fun SlotBasedPanedNavigationState.adaptTo( } return SlotBasedPanedNavigationState( - backStackIds.let popCheck@{ ids -> + isPop = backStackIds.let popCheck@{ ids -> if (ids.size >= previous.backStackIds.size) return@popCheck false if (ids.isEmpty()) return@popCheck true From d60724ea3b2f34d597261243072a7c0408e7f9fd Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 27 Jun 2025 21:27:20 -0400 Subject: [PATCH 61/78] Rearranged method --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index e5503bf..5b6bf4b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -284,10 +284,10 @@ private class MultiPaneDisplayScene( private val backstackIds: List, private val destination: Destination, private val slots: Set, + private val currentPanedNavigationState: SlotBasedPanedNavigationState, + private val onSceneDisposed: () -> Unit, private val isPreviewingBack: () -> Boolean, private val panesToDestinations: @Composable (Destination) -> Map, - private val onSceneDisposed: () -> Unit, - private val currentPanedNavigationState: SlotBasedPanedNavigationState, private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), ) : Scene { From 2f69e7273e980f36ff3f9f3f8cc7a350ae1f82e8 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:13:51 -0400 Subject: [PATCH 62/78] Fix twitching animations --- .../treenav/compose/threepane/ThreePane.kt | 2 +- .../treenav/compose/MultiPaneDisplay.kt | 68 +++++++++++++------ .../treenav/compose/MultiPaneDisplayState.kt | 2 - .../com/tunjid/treenav/compose/PaneScope.kt | 25 ++++--- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 8dcfd52..2977b4f 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -102,7 +102,7 @@ private val DefaultFadeOut = fadeOut( private fun PaneScope.canAnimate() = when { - inPredictiveBack && isActive -> false + inPredictiveBack && isActive -> true paneState.adaptations.any { adaptation -> adaptation is Adaptation.Same } -> false diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 5b6bf4b..eba04c6 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -58,7 +57,6 @@ import com.tunjid.treenav.compose.navigation3.ui.Scene import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator import kotlinx.coroutines.CancellationException -import kotlin.jvm.JvmInline /** * Scope that provides context about individual panes [Pane] in an [MultiPaneDisplay]. @@ -128,6 +126,8 @@ fun MultiPaneDisplay( } } + val backPreviewState = remember { mutableStateOf(BackStatus.No.Commited) } + val slots = remember { List( size = state.panes.size, @@ -155,7 +155,7 @@ fun MultiPaneDisplay( state = state, slots = slots, currentPanedNavigationState = panedNavigationState::value, - isPreviewingBack = state.backPreviewState::value, + backStatus = backPreviewState::value, content = content, ) } @@ -199,11 +199,11 @@ fun MultiPaneDisplay( ) { progress -> try { progress.collect { - state.backPreviewState.value = true + backPreviewState.value = BackStatus.InProgress } - state.backPreviewState.value = false + backPreviewState.value = BackStatus.No.Commited } catch (e: CancellationException) { - state.backPreviewState.value = false + backPreviewState.value = BackStatus.No.Cancelled } } } @@ -212,7 +212,7 @@ fun MultiPaneDisplay( private class MultiPanePaneSceneStrategy( private val state: MultiPaneDisplayState, private val slots: Set, - private val isPreviewingBack: () -> Boolean, + private val backStatus: () -> BackStatus, private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, private val content: @Composable (MultiPaneDisplayScope.() -> Unit), ) : SceneStrategy { @@ -236,6 +236,8 @@ private class MultiPanePaneSceneStrategy( - private val sceneKey: MultiPaneSceneKey, + sceneKey: MultiPaneSceneKey, override val entries: List>, override val previousEntries: List>, private val backstackIds: List, @@ -286,7 +291,7 @@ private class MultiPaneDisplayScene( private val slots: Set, private val currentPanedNavigationState: SlotBasedPanedNavigationState, private val onSceneDisposed: () -> Unit, - private val isPreviewingBack: () -> Boolean, + private val backStatus: () -> BackStatus, private val panesToDestinations: @Composable (Destination) -> Map, private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), ) : Scene { @@ -297,7 +302,7 @@ private class MultiPaneDisplayScene( val multiPaneDisplayScope = object : MultiPaneDisplayScope { override val inPredictiveBack: Boolean - get() = isPreviewingBack() + get() = false override val panes: Collection get() = panedNavigationState.panesToDestinations.keys @@ -315,13 +320,8 @@ private class MultiPaneDisplayScene( val scope = remember { AnimatedPaneScope( - isPreviewingBack = isPreviewingBack, + backStatus = backStatus, paneState = paneState, - activeState = derivedStateOf { - val targetSceneKey = - animatedContentScope.transition.sceneTargetDestinationKey - sceneKey == targetSceneKey - }, animatedContentScope = animatedContentScope, ) }.also { @@ -416,10 +416,34 @@ private fun SlotBasedPanedNavigationState, -) + val isPreviewingBack: Boolean, +) { + + private val idsHash = ids.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as MultiPaneSceneKey + + return ids == other.ids + } + + override fun hashCode(): Int { + return idsHash + } +} + +internal sealed class BackStatus { + data object InProgress : BackStatus() + sealed class No : BackStatus() { + data object Commited : No() + data object Cancelled : No() + } +} private val AlwaysTrue = { true } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 6248130..7b33b33 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -22,7 +22,6 @@ import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf import com.tunjid.treenav.Node import com.tunjid.treenav.compose.navigation3.runtime.NavEntry import com.tunjid.treenav.compose.transforms.PaneTransform @@ -58,7 +57,6 @@ class MultiPaneDisplayState in internal val destinationPanes: @Composable (Destination) -> Map, internal val destinationContent: @Composable PaneScope.(PaneEntry, Destination) -> Unit, ) { - internal val backPreviewState = mutableStateOf(false) internal val navEntryProvider = { destination: Destination -> val paneEntry = paneEntryProvider(destination) 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 4e00f75..996f9f9 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 @@ -21,7 +21,6 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState import androidx.compose.animation.core.Transition import androidx.compose.runtime.Stable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -61,8 +60,7 @@ interface PaneScope : AnimatedVisibilityScope { */ @Stable internal class AnimatedPaneScope( - val isPreviewingBack: () -> Boolean, - val activeState: State, + val backStatus: () -> BackStatus, paneState: PaneState, animatedContentScope: AnimatedContentScope, ) : PaneScope, AnimatedVisibilityScope by animatedContentScope { @@ -73,21 +71,22 @@ internal class AnimatedPaneScope( override var paneState by mutableStateOf(paneState) override val isActive: Boolean - get() = when { - inPredictiveBack -> !activeState.value - // Transition lagging predictive back for the enter state - activeState.value && !isEntering -> true - // Transition lagging predictive back for the exit state - !activeState.value && isEntering -> false - // Stabilized, the transition is the source of truth - else -> activeState.value - } + get() = if (inPredictiveBack) !isEntering else isEntering override val inPredictiveBack: Boolean get() { val currentSize = transition.sceneCurrentDestinationKey?.ids?.size ?: 0 val targetSize = transition.sceneTargetDestinationKey?.ids?.size ?: 0 - return isPreviewingBack() && (targetSize < currentSize) + + val targetIsPreview = transition.sceneTargetDestinationKey?.isPreviewingBack == true + + val isAnimatingBack = targetSize < currentSize + + return when (backStatus()) { + BackStatus.InProgress -> isAnimatingBack && targetIsPreview + BackStatus.No.Cancelled -> isAnimatingBack && targetIsPreview + BackStatus.No.Commited -> false + } } } From b7aeb659d5337a108b20c556a293ab91dc499013 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:20:46 -0400 Subject: [PATCH 63/78] Clean up code --- .../tunjid/treenav/compose/MultiPaneDisplay.kt | 15 +-------------- .../com/tunjid/treenav/compose/PaneScope.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index eba04c6..7ebe255 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -21,7 +21,6 @@ import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.Transition import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -453,19 +452,7 @@ private val AnimatedContentTransitionScope<*>.sceneDestinationKey: MultiPaneScen return target.second as MultiPaneSceneKey } -internal val Transition<*>.sceneTargetDestinationKey: MultiPaneSceneKey? - get() { - val target = parentTransition?.targetState as? Pair<*, *> ?: return null - return target.second as MultiPaneSceneKey - } - -internal val Transition<*>.sceneCurrentDestinationKey: MultiPaneSceneKey? - get() { - val target = parentTransition?.currentState as? Pair<*, *> ?: return null - return target.second as MultiPaneSceneKey - } - -internal val LocalPaneScope = staticCompositionLocalOf> { +private val LocalPaneScope = staticCompositionLocalOf> { throw IllegalArgumentException( "PaneScope should not be read until provided in the composition" ) 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 996f9f9..2f0acec 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 @@ -117,4 +117,16 @@ internal data class SlotPaneState( @JvmInline internal value class Slot internal constructor(val index: Int) +private val Transition<*>.sceneTargetDestinationKey: MultiPaneSceneKey? + get() { + val target = parentTransition?.targetState as? Pair<*, *> ?: return null + return target.second as MultiPaneSceneKey + } + +private val Transition<*>.sceneCurrentDestinationKey: MultiPaneSceneKey? + get() { + val target = parentTransition?.currentState as? Pair<*, *> ?: return null + return target.second as MultiPaneSceneKey + } + internal expect fun Any.identityHash(): Int From a3b7b47e03dffc93256b34faa2fcece6f1005db9 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:22:47 -0400 Subject: [PATCH 64/78] More clean up --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 7ebe255..96664aa 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -255,7 +255,6 @@ private class MultiPanePaneSceneStrategy( - sceneKey: MultiPaneSceneKey, override val entries: List>, override val previousEntries: List>, - private val backstackIds: List, + private val sceneKey: MultiPaneSceneKey, private val destination: Destination, private val slots: Set, private val currentPanedNavigationState: SlotBasedPanedNavigationState, @@ -371,7 +369,7 @@ private class MultiPaneDisplayScene( override val content: @Composable () -> Unit = { currentPanedNavigationState.rememberUpdatedPanedNavigationState( - backStackIds = backstackIds, + backStackIds = sceneKey.ids, panesToDestinations = panesToDestinations(destination), slots = slots, ).also { panedNavigationState = it.value } From ec5683714cfe507b82fb749e835437bc49f150a4 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:27:08 -0400 Subject: [PATCH 65/78] More performance optimizations --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 96664aa..c1693df 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -224,7 +224,9 @@ private class MultiPanePaneSceneStrategy Unit ): Scene { - val backstackIds = entries.map { it.id } + val backstackIds = remember(entries.identityHash()) { + entries.map { it.id } + } return remember(backstackIds) { From 7fe27a60255e8ecf63152896a0eac5ad82f34c95 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:37:57 -0400 Subject: [PATCH 66/78] Better names --- .../tunjid/treenav/compose/MultiPaneDisplay.kt | 16 ++++++++-------- .../com/tunjid/treenav/compose/PaneScope.kt | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index c1693df..f02fc64 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -125,7 +125,7 @@ fun MultiPaneDisplay( } } - val backPreviewState = remember { mutableStateOf(BackStatus.No.Commited) } + val backPreviewState = remember { mutableStateOf(BackStatus.Completed.Commited) } val slots = remember { List( @@ -198,11 +198,11 @@ fun MultiPaneDisplay( ) { progress -> try { progress.collect { - backPreviewState.value = BackStatus.InProgress + backPreviewState.value = BackStatus.Seeking } - backPreviewState.value = BackStatus.No.Commited + backPreviewState.value = BackStatus.Completed.Commited } catch (e: CancellationException) { - backPreviewState.value = BackStatus.No.Cancelled + backPreviewState.value = BackStatus.Completed.Cancelled } } } @@ -437,10 +437,10 @@ internal class MultiPaneSceneKey( } internal sealed class BackStatus { - data object InProgress : BackStatus() - sealed class No : BackStatus() { - data object Commited : No() - data object Cancelled : No() + data object Seeking : BackStatus() + sealed class Completed : BackStatus() { + data object Commited : Completed() + data object Cancelled : Completed() } } 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 2f0acec..30b28c4 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 @@ -83,9 +83,9 @@ internal class AnimatedPaneScope( val isAnimatingBack = targetSize < currentSize return when (backStatus()) { - BackStatus.InProgress -> isAnimatingBack && targetIsPreview - BackStatus.No.Cancelled -> isAnimatingBack && targetIsPreview - BackStatus.No.Commited -> false + BackStatus.Seeking -> isAnimatingBack && targetIsPreview + BackStatus.Completed.Cancelled -> isAnimatingBack && targetIsPreview + BackStatus.Completed.Commited -> false } } } From 01fc324efa09b580bba98a07244f34bd81ce4bb5 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:40:28 -0400 Subject: [PATCH 67/78] Remove inPredictiveBack from MultiPaneDisplayScope --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index f02fc64..3333ca9 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -63,8 +63,6 @@ import kotlinx.coroutines.CancellationException @Stable interface MultiPaneDisplayScope { - val inPredictiveBack: Boolean - val panes: Collection @Composable @@ -300,9 +298,6 @@ private class MultiPaneDisplayScene( @Stable val multiPaneDisplayScope = object : MultiPaneDisplayScope { - override val inPredictiveBack: Boolean - get() = false - override val panes: Collection get() = panedNavigationState.panesToDestinations.keys From 2fb6104f5ab2b43017de6edbb669ff6a437ccba6 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:47:40 -0400 Subject: [PATCH 68/78] Update kdoc --- .../kotlin/com/tunjid/treenav/compose/PaneScope.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 30b28c4..a9f695e 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 @@ -39,9 +39,8 @@ interface PaneScope : AnimatedVisibilityScope { val paneState: PaneState /** - * Whether or not this [PaneScope] is active in its current pane. It is active when this pane - * matches the current navigation destination or any of its co-displayed panes in a given - * scene. + * Whether or not this [PaneScope] is active in its current pane. It is active when this pane's + * transition matches the pane for the current navigation destination in a given scene. * * This means that during predictive back animations, the outgoing panes, i.e the panes * whose [AnimatedVisibilityScope.transition] have their [Transition.targetState] From 362a644d2ab58ba8063d1ce9848f1e3b4c01177c Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:52:21 -0400 Subject: [PATCH 69/78] Tidying up MultiPaneDisplay --- .../kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 3333ca9..923b5fe 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -295,6 +295,8 @@ private class MultiPaneDisplayScene( private var panedNavigationState by mutableStateOf(currentPanedNavigationState) + override val key: Any = sceneKey + @Stable val multiPaneDisplayScope = object : MultiPaneDisplayScope { @@ -361,8 +363,6 @@ private class MultiPaneDisplayScene( panedNavigationState.destinationFor(pane) } - override val key: Any = sceneKey - override val content: @Composable () -> Unit = { currentPanedNavigationState.rememberUpdatedPanedNavigationState( From 6ede4f8dc89c8049e05b48af1290b9cd634792f1 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:57:17 -0400 Subject: [PATCH 70/78] Make PaneDestinationMultiPaneDisplayScope a concrete class --- .../treenav/compose/MultiPaneDisplay.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 923b5fe..fab424d 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.SaveableStateHolder -import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle @@ -288,17 +287,30 @@ private class MultiPaneDisplayScene( private val slots: Set, private val currentPanedNavigationState: SlotBasedPanedNavigationState, private val onSceneDisposed: () -> Unit, - private val backStatus: () -> BackStatus, + backStatus: () -> BackStatus, private val panesToDestinations: @Composable (Destination) -> Map, private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), ) : Scene { - private var panedNavigationState by mutableStateOf(currentPanedNavigationState) + private val panedNavigationState = mutableStateOf(currentPanedNavigationState) override val key: Any = sceneKey @Stable - val multiPaneDisplayScope = object : MultiPaneDisplayScope { + val multiPaneDisplayScope = PaneDestinationMultiPaneDisplayScope( + panedNavigationState = panedNavigationState, + entries = entries, + backStatus = backStatus, + ) + + @Stable + class PaneDestinationMultiPaneDisplayScope( + panedNavigationState: State>, + private val entries: List>, + private val backStatus: () -> BackStatus, + ) : MultiPaneDisplayScope { + + private val panedNavigationState by panedNavigationState override val panes: Collection get() = panedNavigationState.panesToDestinations.keys @@ -369,7 +381,7 @@ private class MultiPaneDisplayScene( backStackIds = sceneKey.ids, panesToDestinations = panesToDestinations(destination), slots = slots, - ).also { panedNavigationState = it.value } + ).also { panedNavigationState.value = it.value } multiPaneDisplayScope.scopeContent() From 85e52863f5daf4e32c396b40ce4bf3239f65c8ad Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 10:58:45 -0400 Subject: [PATCH 71/78] Cleaned up code --- .../treenav/compose/MultiPaneDisplay.kt | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index fab424d..a171f68 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -294,8 +294,6 @@ private class MultiPaneDisplayScene( private val panedNavigationState = mutableStateOf(currentPanedNavigationState) - override val key: Any = sceneKey - @Stable val multiPaneDisplayScope = PaneDestinationMultiPaneDisplayScope( panedNavigationState = panedNavigationState, @@ -303,6 +301,23 @@ private class MultiPaneDisplayScene( backStatus = backStatus, ) + override val key: Any = sceneKey + + override val content: @Composable () -> Unit = { + + currentPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds = sceneKey.ids, + panesToDestinations = panesToDestinations(destination), + slots = slots, + ).also { panedNavigationState.value = it.value } + + multiPaneDisplayScope.scopeContent() + + DisposableEffect(Unit) { + onDispose(onSceneDisposed) + } + } + @Stable class PaneDestinationMultiPaneDisplayScope( panedNavigationState: State>, @@ -374,21 +389,6 @@ private class MultiPaneDisplayScene( override fun destinationIn(pane: Pane): Destination? = panedNavigationState.destinationFor(pane) } - - override val content: @Composable () -> Unit = { - - currentPanedNavigationState.rememberUpdatedPanedNavigationState( - backStackIds = sceneKey.ids, - panesToDestinations = panesToDestinations(destination), - slots = slots, - ).also { panedNavigationState.value = it.value } - - multiPaneDisplayScope.scopeContent() - - DisposableEffect(Unit) { - onDispose(onSceneDisposed) - } - } } private fun MultiPaneDisplayState<*, NavigationState, *>.findNavigationStateMatching( From 4b28cb33d0377f34bd137aa5d46c62d75b5d58e4 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 11:07:35 -0400 Subject: [PATCH 72/78] Update DecoratedNavEntryProvider to latest --- .../runtime/DecoratedNavEntryProvider.kt | 29 ++++++------------- libraryVersion.properties | 8 ++--- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt index 3756fc6..ad9f931 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt @@ -50,9 +50,9 @@ internal fun DecoratedNavEntryProvider( // to ensure our lambda below takes the correct type entryProvider as (T) -> NavEntry val entries = - backStack.mapIndexed { index, key -> + backStack.map { key -> val entry = entryProvider.invoke(key) - decorateEntry(entry, entryDecorators as List>) + decorateEntry(entry, entryDecorators) } // Provides the entire backstack to the previously wrapped entries @@ -72,36 +72,30 @@ internal fun DecoratedNavEntryProvider( @Composable internal fun decorateEntry( entry: NavEntry, - decorators: List>, + decorators: List>, ): NavEntry { + val latestDecorators by rememberUpdatedState(decorators) val initial = object : NavEntryWrapper(entry) { @Composable override fun Content() { val localInfo = LocalNavEntryDecoratorLocalInfo.current val idsInComposition = localInfo.idsInComposition - - // store onPop for every decorator that has ever decorated this entry - // so that onPop will be called for newly added or removed decorators as well - val popCallbacks = remember { LinkedHashSet<(Any) -> Unit>() } - DisposableEffect(key1 = contentKey) { idsInComposition.add(contentKey) onDispose { val notInComposition = idsInComposition.remove(contentKey) val popped = !localInfo.contentKeys.contains(contentKey) if (popped && notInComposition) { - // we reverse the scopes before popping to imitate the order // of onDispose calls if each scope/decorator had their own // onDispose // calls for clean up // convert to mutableList first for backwards compat. - popCallbacks.toMutableList().reversed().forEach { it(contentKey) } + latestDecorators.reversed().forEach { it.onPop(contentKey) } } } } - decorators.distinct().forEach { decorator -> popCallbacks.add(decorator.onPop) } DecorateNavEntry(entry, decorators) } } @@ -128,27 +122,22 @@ internal fun PrepareBackStack( // update this backStack so that onDispose has access to the latest backStack to check // if an entry has been popped val latestBackStack by rememberUpdatedState(entries.map { it.contentKey }) + val latestDecorators by rememberUpdatedState(decorators) latestBackStack.forEach { contentKey -> contentKeys.add(contentKey) - // store onPop for every decorator has ever decorated this key - // so that onPop will be called for newly added or removed decorators as well - val popCallbacks = remember(contentKey) { LinkedHashSet<(Any) -> Unit>() } - decorators.distinct().forEach { popCallbacks.add(it.onPop) } DisposableEffect(contentKey) { onDispose { - val originalRoot = entries.first().contentKey - val sameBackStack = originalRoot == latestBackStack.first() val popped = - if (sameBackStack && !latestBackStack.contains(contentKey)) { + if (!latestBackStack.contains(contentKey)) { contentKeys.remove(contentKey) } else false // run onPop callback if (popped && !localInfo.idsInComposition.contains(contentKey)) { // we reverse the order before popping to imitate the order // of onDispose calls if each scope/decorator had their own onDispose - // calls for clean up. convert to mutableList first for backwards compat. - popCallbacks.toMutableList().reversed().forEach { it(contentKey) } + // calls for clean up + latestDecorators.reversed().forEach { it.onPop(contentKey) } } } } diff --git a/libraryVersion.properties b/libraryVersion.properties index 5bcb90a..da72d84 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,7 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.30d -strings_version=0.0.30d -compose_version=0.0.30d -compose-threepane_version=0.0.30d \ No newline at end of file +treenav_version=0.0.30e +strings_version=0.0.30e +compose_version=0.0.30e +compose-threepane_version=0.0.30e \ No newline at end of file From 6bd74d2cacce158c27e812062ece46ff4b5b2ac7 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 13:21:49 -0400 Subject: [PATCH 73/78] Make implementations of PaneTransform internal --- .../MovableSharedElementTransform.kt | 12 ++-- .../transforms/ThreePaneAdaptiveTransform.kt | 10 ++-- .../treenav/compose/MultiPaneDisplayState.kt | 14 ++--- .../transforms/PaneModifierTransform.kt | 6 +- .../treenav/compose/transforms/Transforms.kt | 58 ++++++++++++++++--- .../com/tunjid/demo/common/ui/DemoApp.kt | 4 +- 6 files changed, 72 insertions(+), 32 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 93e2dd0..39c9e1c 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -41,11 +41,11 @@ import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScop import com.tunjid.treenav.compose.moveablesharedelement.PaneMovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.rememberPaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.transforms.RenderTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.PaneTransform +import com.tunjid.treenav.compose.transforms.paneRenderTransform /** - * A [Transform] that applies semantics of movable shared elements to + * A [PaneTransform] that applies semantics of movable shared elements to * [ThreePane] layouts. * * It is an opinionated implementation that always shows the movable shared element in @@ -62,8 +62,8 @@ import com.tunjid.treenav.compose.transforms.Transform fun threePanedMovableSharedElementTransform( movableSharedElementHostState: MovableSharedElementHostState, -): Transform = - RenderTransform { destination, previousTransform -> +): PaneTransform = + paneRenderTransform { destination, destinationPaneMapper -> val delegate = rememberPaneMovableSharedElementScope( movableSharedElementHostState = movableSharedElementHostState ) @@ -76,7 +76,7 @@ fun ) } - previousTransform(movableSharedElementScope, destination) + destinationPaneMapper(movableSharedElementScope, destination) } /** diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt index b13bca2..8585ef0 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt @@ -26,10 +26,10 @@ import androidx.compose.ui.unit.dp import com.tunjid.treenav.Node import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.transforms.PaneTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.paneMappingTransform /** - * An [Transform] that selectively displays panes for a [ThreePane] layout + * An [PaneTransform] 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. @@ -39,8 +39,8 @@ fun windowWidthState: State, secondaryPaneBreakPoint: State = mutableStateOf(SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP), tertiaryPaneBreakPoint: State = mutableStateOf(TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP), -): Transform = - PaneTransform { destination, previousTransform -> +): PaneTransform = + paneMappingTransform { destination, destinationPaneMapper -> val showSecondary by remember { derivedStateOf { windowWidthState.value >= secondaryPaneBreakPoint.value } } @@ -48,7 +48,7 @@ fun derivedStateOf { windowWidthState.value >= tertiaryPaneBreakPoint.value } } - val originalMapping = previousTransform(destination) + val originalMapping = destinationPaneMapper(destination) val primaryNode = originalMapping[ThreePane.Primary] mapOf( ThreePane.Primary to primaryNode, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 7b33b33..fb41804 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -24,9 +24,9 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.State import com.tunjid.treenav.Node import com.tunjid.treenav.compose.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.transforms.PaneMappingTransform import com.tunjid.treenav.compose.transforms.PaneTransform -import com.tunjid.treenav.compose.transforms.RenderTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.PaneRenderTransform /** * Class for configuring a [MultiPaneDisplay] for selecting, adapting and placing navigation @@ -115,14 +115,14 @@ class MultiPaneDisplayState in * @param destinationTransform a transform of the [navigationState] to its current destination. * @param popTransform a transform of the [navigationState] when back is pressed. * @param onPopped an action to perform when the navigation state has been popped to a new state. - * @param entryProvider provides the [Transform]s and content needed to render + * @param entryProvider provides the [PaneTransform]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, - transforms: List>, + transforms: List>, navigationState: State, backStackTransform: (NavigationState) -> List, destinationTransform: (NavigationState) -> Destination, @@ -154,7 +154,7 @@ fun MultiPaneDisplayState( private operator fun MultiPaneDisplayState.plus( - transform: Transform, + transform: PaneTransform, ): MultiPaneDisplayState = MultiPaneDisplayState( panes = panes, @@ -166,7 +166,7 @@ private operator fun transitionSpec = transitionSpec, paneEntryProvider = paneEntryProvider, destinationPanes = when (transform) { - is PaneTransform -> { destination -> + is PaneMappingTransform -> { destination -> transform.toPanesAndDestinations( destination = destination, previousTransform = destinationPanes, @@ -176,7 +176,7 @@ private operator fun else -> destinationPanes }, destinationContent = when (transform) { - is RenderTransform -> { paneEntry, destination -> + is PaneRenderTransform -> { paneEntry, destination -> with(transform) { Render( destination = destination, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt index 0f5f970..75dd5dc 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt @@ -30,11 +30,11 @@ import com.tunjid.treenav.compose.PaneScope */ fun paneModifierTransform( paneModifier: PaneScope.() -> Modifier = { Modifier }, -): Transform = - RenderTransform { destination, previousTransform -> +): PaneTransform = + paneRenderTransform { destination, destinationContent -> Box( modifier = paneModifier() ) { - previousTransform(destination) + destinationContent(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 index d8dc979..a4a3ef3 100644 --- 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 @@ -9,13 +9,13 @@ import com.tunjid.treenav.compose.PaneScope /** * Provides APIs for adjusting what is presented in a [MultiPaneDisplay]. */ -sealed interface Transform +sealed interface PaneTransform /** - * A [Transform] that allows for changing which [Destination] shows in which [Pane]. + * A [PaneTransform] that allows for changing which [Destination] shows in which [Pane]. */ -fun interface PaneTransform - : Transform { +internal fun interface PaneMappingTransform + : PaneTransform { /** * Given the current [Destination], provide what [Destination] to show in a [Pane]. @@ -23,7 +23,7 @@ fun interface PaneTransform * 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 + * @param previousTransform a [PaneTransform] that when invoked, returns the pane to destination * mapping for the current [Destination] pre-transform that can then be composed with new logic. */ @Composable @@ -34,18 +34,18 @@ fun interface PaneTransform } /** - * A [Transform] that allows for the rendering semantics of a [Destination] in a given + * A [PaneTransform] that allows for the rendering semantics of a [Destination] in a given * [PaneScope]. */ -fun interface RenderTransform - : Transform { +internal fun interface PaneRenderTransform + : PaneTransform { /** * 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] + * @param previousTransform a [PaneTransform] that when invoked, renders the [Destination] * for the [PaneScope ]pre-transform that can then be composed with new logic. */ @Composable @@ -54,3 +54,43 @@ fun interface RenderTransform previousTransform: @Composable PaneScope.(Destination) -> Unit, ) } + +/** + * Given the current [Destination], provide what [Destination]s to show in each of the [Pane]s + * available. + * + * Each [Destination] in the returned mapping must already exist in the + * back stack derived from the [MultiPaneDisplayState.navigationState]. + * + * @param mappingTransform a lambda providing the mapping. It has two arguments: + * - destination: The [Destination] for which it panes will be displayed. + * - destinationPaneMapper: A lambda that when invoked, returns the pane to destination + * mapping for the current [Destination] pre-transform that can then be composed with new logic. + */ +fun paneMappingTransform( + mappingTransform: @Composable ( + destination: Destination, + destinationPaneMapper: @Composable (Destination) -> Map + ) -> Map +): PaneTransform = + PaneMappingTransform { destination, previousTransform -> + mappingTransform(destination, previousTransform) + } + +/** + * A [PaneTransform] that allows for adjusting the rendering semantics of a [Destination] in a + * for a given [Pane] in the [PaneScope]. + * + * @param renderTransform a lambda providing the Composable to render. It has two arguments: + * - destination: The [Destination] being rendered in the provided [PaneScope]. + * - destinationContent: A lambda that when invoked, renders the [Destination] pre-transform + */ +fun paneRenderTransform( + renderTransform: @Composable PaneScope.( + destination: Destination, + destinationContent: @Composable PaneScope.(Destination) -> Unit + ) -> Unit +): PaneTransform = + PaneRenderTransform { destination, previousTransform -> + renderTransform(destination, previousTransform) + } 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 2cff786..7f12e8a 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 @@ -87,7 +87,7 @@ import com.tunjid.treenav.compose.navigation3.ui.NavigationEventHandler import com.tunjid.treenav.compose.threepane.ThreePane 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.PaneTransform import com.tunjid.treenav.compose.transforms.paneModifierTransform import com.tunjid.treenav.pop import com.tunjid.treenav.popToRoot @@ -326,7 +326,7 @@ class AppState( companion object { @Composable fun AppState.rememberMultiPaneDisplayState( - transforms: List>, + transforms: List>, ): MultiPaneDisplayState { val displayState = remember { MultiPaneDisplayState( From c97f37f10036b0fa7554e008f259dc1282418755 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 13:25:24 -0400 Subject: [PATCH 74/78] Update backStatusState in snapshot --- .../treenav/compose/MultiPaneDisplay.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index a171f68..b0cfa6f 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle @@ -106,6 +107,11 @@ fun MultiPaneDisplay( content: @Composable MultiPaneDisplayScope.() -> Unit, ) { val navigationState by state.navigationState + + val backStatusState = remember { + mutableStateOf(BackStatus.Completed.Commited) + } + val panesToDestinations = rememberUpdatedState( state.destinationPanes( state.destinationTransform(navigationState) @@ -117,13 +123,14 @@ fun MultiPaneDisplay( val sameBackStack = currentBackStack == mutableBackStack if (sameBackStack) return@let - mutableBackStack.clear() - mutableBackStack.addAll(currentBackStack) + Snapshot.withMutableSnapshot { + mutableBackStack.clear() + mutableBackStack.addAll(currentBackStack) + backStatusState.value = BackStatus.Completed.Commited + } } } - val backPreviewState = remember { mutableStateOf(BackStatus.Completed.Commited) } - val slots = remember { List( size = state.panes.size, @@ -151,7 +158,7 @@ fun MultiPaneDisplay( state = state, slots = slots, currentPanedNavigationState = panedNavigationState::value, - backStatus = backPreviewState::value, + backStatus = backStatusState::value, content = content, ) } @@ -195,11 +202,11 @@ fun MultiPaneDisplay( ) { progress -> try { progress.collect { - backPreviewState.value = BackStatus.Seeking + backStatusState.value = BackStatus.Seeking } - backPreviewState.value = BackStatus.Completed.Commited + backStatusState.value = BackStatus.Completed.Commited } catch (e: CancellationException) { - backPreviewState.value = BackStatus.Completed.Cancelled + backStatusState.value = BackStatus.Completed.Cancelled } } } From 456cd6c6029854dbfa0a0e4d267afa2ad90a1fcc Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 13:43:23 -0400 Subject: [PATCH 75/78] Update three pane animations --- .../kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 2977b4f..40e4edc 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -16,6 +16,7 @@ package com.tunjid.treenav.compose.threepane +import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.FiniteAnimationSpec @@ -102,6 +103,7 @@ private val DefaultFadeOut = fadeOut( private fun PaneScope.canAnimate() = when { + transition.targetState == EnterExitState.PostExit -> true inPredictiveBack && isActive -> true paneState.adaptations.any { adaptation -> adaptation is Adaptation.Same From 56ecec86a8d0b0240325c29b35b04d157a4da636 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 13:43:57 -0400 Subject: [PATCH 76/78] Make parameter name explicit --- .../kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 40e4edc..2d3856c 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -78,8 +78,8 @@ fun threePaneEntry( exitTransition: PaneScope.() -> ExitTransition = { if (canAnimate()) DefaultFadeOut else ExitTransition.None }, - paneMapping: @Composable (Destination) -> Map = { - mapOf(ThreePane.Primary to it) + paneMapping: @Composable (Destination) -> Map = { destination -> + mapOf(ThreePane.Primary to destination) }, render: @Composable (PaneScope.(Destination) -> Unit), ) = PaneEntry( From 06e515632cb15fbfc2958cd633f79fc33b8d7888 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 13:49:58 -0400 Subject: [PATCH 77/78] Establish generics order of NavigationState, Destination, Pane --- .../transforms/MovableSharedElementTransform.kt | 2 +- .../transforms/ThreePaneAdaptiveTransform.kt | 2 +- .../tunjid/treenav/compose/MultiPaneDisplay.kt | 10 +++++----- .../treenav/compose/MultiPaneDisplayState.kt | 16 ++++++++-------- .../compose/transforms/PaneModifierTransform.kt | 4 ++-- .../treenav/compose/transforms/Transforms.kt | 12 ++++++------ .../kotlin/com/tunjid/demo/common/ui/DemoApp.kt | 8 ++------ 7 files changed, 25 insertions(+), 29 deletions(-) diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 39c9e1c..28496d9 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -62,7 +62,7 @@ import com.tunjid.treenav.compose.transforms.paneRenderTransform fun threePanedMovableSharedElementTransform( movableSharedElementHostState: MovableSharedElementHostState, -): PaneTransform = +): PaneTransform = paneRenderTransform { destination, destinationPaneMapper -> val delegate = rememberPaneMovableSharedElementScope( movableSharedElementHostState = movableSharedElementHostState diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt index 8585ef0..bca561a 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt @@ -39,7 +39,7 @@ fun windowWidthState: State, secondaryPaneBreakPoint: State = mutableStateOf(SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP), tertiaryPaneBreakPoint: State = mutableStateOf(TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP), -): PaneTransform = +): PaneTransform = paneMappingTransform { destination, destinationPaneMapper -> val showSecondary by remember { derivedStateOf { windowWidthState.value >= secondaryPaneBreakPoint.value } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index b0cfa6f..eeba64a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -101,8 +101,8 @@ interface MultiPaneDisplayScope { * transforms for each navigation destination shown in the [MultiPaneDisplay]. */ @Composable -fun MultiPaneDisplay( - state: MultiPaneDisplayState, +fun MultiPaneDisplay( + state: MultiPaneDisplayState, modifier: Modifier = Modifier, content: @Composable MultiPaneDisplayScope.() -> Unit, ) { @@ -212,8 +212,8 @@ fun MultiPaneDisplay( } @Stable -private class MultiPanePaneSceneStrategy( - private val state: MultiPaneDisplayState, +private class MultiPanePaneSceneStrategy( + private val state: MultiPaneDisplayState, private val slots: Set, private val backStatus: () -> BackStatus, private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, @@ -398,7 +398,7 @@ private class MultiPaneDisplayScene( } } -private fun MultiPaneDisplayState<*, NavigationState, *>.findNavigationStateMatching( +private fun MultiPaneDisplayState.findNavigationStateMatching( backstackIds: List, ): NavigationState { var state = navigationState.value diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index fb41804..7df2e03 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -45,7 +45,7 @@ import com.tunjid.treenav.compose.transforms.PaneRenderTransform * @param destinationContent the transform used to render a [Destination] in its pane. */ @Stable -class MultiPaneDisplayState internal constructor( +class MultiPaneDisplayState internal constructor( internal val panes: List, internal val navigationState: State, internal val backStackTransform: (NavigationState) -> List, @@ -120,9 +120,9 @@ class MultiPaneDisplayState in * @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( +fun MultiPaneDisplayState( panes: List, - transforms: List>, + transforms: List>, navigationState: State, backStackTransform: (NavigationState) -> List, destinationTransform: (NavigationState) -> Destination, @@ -149,13 +149,13 @@ fun MultiPaneDisplayState( paneEntry.content(this@transform, destination) } ), - operation = MultiPaneDisplayState::plus + operation = MultiPaneDisplayState::plus ) -private operator fun - MultiPaneDisplayState.plus( - transform: PaneTransform, -): MultiPaneDisplayState = +private operator fun + MultiPaneDisplayState.plus( + transform: PaneTransform, +): MultiPaneDisplayState = MultiPaneDisplayState( panes = panes, navigationState = navigationState, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt index 75dd5dc..bacfb13 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt @@ -28,9 +28,9 @@ import com.tunjid.treenav.compose.PaneScope * * @param paneModifier a lambda for specifying the [Modifier] for each [Pane] in a [PaneScope]. */ -fun paneModifierTransform( +fun paneModifierTransform( paneModifier: PaneScope.() -> Modifier = { Modifier }, -): PaneTransform = +): PaneTransform = paneRenderTransform { destination, destinationContent -> Box( modifier = paneModifier() 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 index a4a3ef3..3cf6312 100644 --- 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 @@ -9,13 +9,13 @@ import com.tunjid.treenav.compose.PaneScope /** * Provides APIs for adjusting what is presented in a [MultiPaneDisplay]. */ -sealed interface PaneTransform +sealed interface PaneTransform /** * A [PaneTransform] that allows for changing which [Destination] shows in which [Pane]. */ internal fun interface PaneMappingTransform - : PaneTransform { + : PaneTransform { /** * Given the current [Destination], provide what [Destination] to show in a [Pane]. @@ -38,7 +38,7 @@ internal fun interface PaneMappingTransform * [PaneScope]. */ internal fun interface PaneRenderTransform - : PaneTransform { + : PaneTransform { /** * Given the current [Destination], and its [PaneScope], compose additional presentation @@ -67,12 +67,12 @@ internal fun interface PaneRenderTransform * - destinationPaneMapper: A lambda that when invoked, returns the pane to destination * mapping for the current [Destination] pre-transform that can then be composed with new logic. */ -fun paneMappingTransform( +fun paneMappingTransform( mappingTransform: @Composable ( destination: Destination, destinationPaneMapper: @Composable (Destination) -> Map ) -> Map -): PaneTransform = +): PaneTransform = PaneMappingTransform { destination, previousTransform -> mappingTransform(destination, previousTransform) } @@ -90,7 +90,7 @@ fun paneRenderTransform( destination: Destination, destinationContent: @Composable PaneScope.(Destination) -> Unit ) -> Unit -): PaneTransform = +): PaneTransform = PaneRenderTransform { destination, previousTransform -> renderTransform(destination, previousTransform) } 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 7f12e8a..da3a9f3 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 @@ -319,15 +319,11 @@ class AppState( fun isInteractingWithPanes(): Boolean = paneInteractionSourceList.any { it.isActive() } - fun goBack() { - navigationRepository.navigate(MultiStackNav::pop) - } - companion object { @Composable fun AppState.rememberMultiPaneDisplayState( - transforms: List>, - ): MultiPaneDisplayState { + transforms: List>, + ): MultiPaneDisplayState { val displayState = remember { MultiPaneDisplayState( panes = ThreePane.entries.toList(), From cce35e2a95a39a335dbac63ab8badb654a5c37e9 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sat, 28 Jun 2025 14:25:24 -0400 Subject: [PATCH 78/78] Easier generic names --- .../treenav/compose/SlotBasedPanedNavigationState.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 8fe8d94..eec6320 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 @@ -55,9 +55,9 @@ internal data class SlotBasedPanedNavigationState( val destinationIdsAnimatingOut: Set, ) { companion object { - internal fun initial( + internal fun initial( slots: Collection, - ): SlotBasedPanedNavigationState = SlotBasedPanedNavigationState( + ): SlotBasedPanedNavigationState = SlotBasedPanedNavigationState( isPop = false, swapAdaptations = emptySet(), panesToDestinations = emptyMap(), @@ -133,11 +133,11 @@ private val ChangeAdaptations = setOf(Adaptation.Change) * A method that adapts changes in navigation to different panes while allowing for them * to be animated easily. */ -internal fun SlotBasedPanedNavigationState.adaptTo( +internal fun SlotBasedPanedNavigationState.adaptTo( slots: Set, - panesToDestinations: Map, + panesToDestinations: Map, backStackIds: List, -): SlotBasedPanedNavigationState { +): SlotBasedPanedNavigationState { val previous = this val previouslyUsedSlots = previous.destinationIdsToAdaptiveSlots @@ -154,7 +154,7 @@ internal fun SlotBasedPanedNavigationState.adaptTo( val unplacedNodeIds = panesToDestinations.values.mapNotNull { it?.id }.toMutableSet() val nodeIdsToAdaptiveSlots = mutableMapOf() - val swapAdaptations = mutableSetOf>() + val swapAdaptations = mutableSetOf>() // Process nodes that swapped panes from old to new for ((toPane, toNode) in panesToDestinations.entries) {