这是indexloc提供的服务,不要输入任何密码
Skip to content

Support movable SavableStateHolder #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ jobs:
java-version: '17'
distribution: 'adopt'
- name: Validate Gradle wrapper
uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b
uses: gradle/actions/wrapper-validation@v3
- name: JVM tests
run: ./gradlew jvmTest
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
package com.tunjid.treenav.compose.threepane.transforms

import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.SharedTransitionScope.OverlayClip
import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize
import androidx.compose.animation.core.Transition
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
Expand All @@ -29,12 +31,13 @@ import androidx.compose.ui.Modifier
import com.tunjid.treenav.Node
import com.tunjid.treenav.compose.MultiPaneDisplay
import com.tunjid.treenav.compose.PaneScope
import com.tunjid.treenav.compose.transforms.RenderTransform
import com.tunjid.treenav.compose.transforms.Transform
import com.tunjid.treenav.compose.PaneState
import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState
import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope
import com.tunjid.treenav.compose.moveablesharedelement.PaneMovableSharedElementScope
import com.tunjid.treenav.compose.threepane.ThreePane
import com.tunjid.treenav.compose.transforms.RenderTransform
import com.tunjid.treenav.compose.transforms.Transform

/**
* A [Transform] that applies semantics of movable shared elements to
Expand Down Expand Up @@ -91,11 +94,20 @@ private class ThreePaneMovableSharedElementScope<Destination : Node>(
private val hostState: MovableSharedElementHostState<ThreePane, Destination>,
private val delegate: PaneMovableSharedElementScope<ThreePane, Destination>,
) : MovableSharedElementScope,
PaneScope<ThreePane, Destination> by delegate.paneScope {
PaneScope<ThreePane, Destination> {

override val sharedTransitionScope: SharedTransitionScope
get() = delegate.sharedTransitionScope

override val transition: Transition<EnterExitState>
get() = delegate.paneScope.transition

override val paneState: PaneState<ThreePane, Destination>
get() = delegate.paneScope.paneState

override val isActive: Boolean
get() = delegate.paneScope.isActive

@OptIn(ExperimentalSharedTransitionApi::class)
override fun <T> movableSharedElementOf(
key: Any,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import com.tunjid.treenav.Node
import com.tunjid.treenav.compose.navigation3.DecoratedNavEntryProvider
import com.tunjid.treenav.compose.navigation3.NavEntry
import com.tunjid.treenav.compose.navigation3.NavEntryDecorator
import com.tunjid.treenav.compose.navigation3.decorators.DefaultViewModelStoreNavEntryDecorator
import com.tunjid.treenav.compose.navigation3.decorators.MovableContentNavEntryDecorator
import com.tunjid.treenav.compose.navigation3.decorators.SaveableStateNavEntryDecorator
import com.tunjid.treenav.compose.navigation3.decorators.SavedStateNavEntryDecorator
import com.tunjid.treenav.compose.navigation3.decorators.TransitionAwareLifecycleNavEntryDecorator
Expand Down Expand Up @@ -79,6 +78,7 @@ internal fun <Destination : Node, NavigationState : Node, Pane> DecoratedNavEntr
)
},
entryDecorators = listOf(
MovableContentNavEntryDecorator,
SaveableStateNavEntryDecorator,
SavedStateNavEntryDecorator,
transitionAwareLifecycleNavEntryDecorator,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.tunjid.treenav.compose

import com.tunjid.treenav.MultiStackNav
import com.tunjid.treenav.Node
import com.tunjid.treenav.StackNav
import com.tunjid.treenav.backStack

/**
* A convenience method for reading the back stack for this [MultiStackNav]
* optimized for consumption for a [MultiPaneDisplay].
*/
inline fun <reified Destination: Node> MultiStackNav.multiPaneDisplayBackstack(): List<Destination> =
backStack(
includeCurrentDestinationChildren = true,
placeChildrenBeforeParent = true,
distinctDestinations = true,
)
.filterIsInstance<Destination>()

/**
* A convenience method for reading the back stack for this [MultiStackNav]
* optimized for consumption for a [MultiPaneDisplay].
*/
inline fun <reified Destination: Node> StackNav.multiPaneDisplayBackstack(): List<Destination> =
backStack(
includeCurrentDestinationChildren = true,
placeChildrenBeforeParent = true,
distinctDestinations = true,
)
.filterIsInstance<Destination>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.tunjid.treenav.compose.navigation3.decorators

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.key
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import com.tunjid.treenav.compose.navigation3.NavEntry
import com.tunjid.treenav.compose.navigation3.NavEntryDecorator


/**
* A [NavEntryDecorator] that wraps each entry in a [movableContentOf] to allow nav displays to
* arbitrarily place entries in different places in the composable call hierarchy.
*
* This should likely be the first [NavEntryDecorator] to ensure that other
* [NavEntryDecorator.DecorateEntry] calls that are stateful are moved properly inside the
* [movableContentOf].
*/
internal object MovableContentNavEntryDecorator : NavEntryDecorator {

@Composable
override fun DecorateBackStack(backStack: List<Any>, content: @Composable (() -> Unit)) {
val backStackKeys = backStack.toSet()

// This is an intricate dance to create a movableContentOf for each entry that is scoped
// to the backstack, that calls the correct updated content.
// First we associate each key in the backstack with a MutableState that will contain
// the actual content of the entry, as updated in DecorateEntry.
// The MutableState's remembered lifecycle precisely matches when its key is in the
// backstack.
val movableContentContentHolderMap: Map<Any, MutableState<@Composable () -> Unit>> =
backStackKeys.associateWith { key ->
key(key) {
remember {
mutableStateOf(
@Composable {
error(
"Should not be called, this should always be updated in" +
"DecorateEntry with the real content"
)
}
)
}
}
}

// Second we create another map containing the movable contents themselves, again
// by associating the backstack key with a remembered movableContentOf
// The critical thing here is that the movableContentOf's remembered lifecycle precisely
// matches when its key is in the backstack.
val movableContentHolderMap: Map<Any, @Composable () -> Unit> =
backStackKeys.associateWith { key ->
key(key) {
remember {
movableContentOf {
// In case the key is removed from the backstack while this is still
// being rendered, we remember the MutableState directly to allow
// rendering it while we are animating out.
remember { movableContentContentHolderMap.getValue(key) }.value()
}
}
}
}
CompositionLocalProvider(
LocalMovableContentNavLocalInfo provides
MovableContentNavLocalInfo(movableContentHolderMap, movableContentContentHolderMap),
content = content,
)
}

@Composable
override fun <T : Any> DecorateEntry(entry: NavEntry<T>) {
val movableContentNavLocalInfo = LocalMovableContentNavLocalInfo.current
key(entry.key) {
// In case the key is removed from the backstack while this is still
// being rendered, we remember the MutableState directly to allow
// updating it while we are animating out.
val movableContentContentHolder = remember {
movableContentNavLocalInfo.movableContentContentHolderMap.getValue(entry.key)
}
// Update the state holder with the actual entry content
movableContentContentHolder.value = { entry.content(entry.key) }
// In case the key is removed from the backstack while this is still
// being rendered, we remember the movableContent directly to allow
// rendering it while we are animating out.
val movableContentHolder = remember {
movableContentNavLocalInfo.movableContentHolderMap.getValue(entry.key)
}
// Finally, render the entry content via the movableContentOf
movableContentHolder()
}
}
}

internal val LocalMovableContentNavLocalInfo =
staticCompositionLocalOf<MovableContentNavLocalInfo> {
error(
"CompositionLocal LocalMovableContentNavLocalInfo not present. You must call " +
"DecorateBackStack before calling DecorateEntry."
)
}

@Immutable
internal class MovableContentNavLocalInfo(
val movableContentHolderMap: Map<Any, @Composable () -> Unit>,
val movableContentContentHolderMap: Map<Any, MutableState<@Composable () -> Unit>>
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,13 @@

package com.tunjid.treenav.compose.navigation3.decorators

import androidx.collection.mutableScatterMapOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.ReusableContent
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
import androidx.compose.runtime.saveable.SaveableStateHolder
import androidx.compose.runtime.saveable.SaveableStateRegistry
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.staticCompositionLocalOf
import com.tunjid.treenav.compose.navigation3.NavEntry
import com.tunjid.treenav.compose.navigation3.NavEntryDecorator
Expand All @@ -45,7 +41,7 @@ internal object SaveableStateNavEntryDecorator : NavEntryDecorator {
val localInfo = remember { SaveableStateNavLocalInfo() }
DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } }

localInfo.savedStateHolder = rememberPanedSaveableStateHolder()
localInfo.savedStateHolder = rememberSaveableStateHolder()
backStack.forEachIndexed { index, key ->
// We update here as part of composition to ensure the value is available to
// DecorateEntry
Expand Down Expand Up @@ -147,87 +143,3 @@ internal class SaveableStateNavLocalInfo {
@Suppress("PrimitiveInCollection") // The order of the element matters
internal val idsInComposition: LinkedHashSet<Int> = LinkedHashSet<Int>()
}

@Composable
internal fun rememberPanedSaveableStateHolder(): SaveableStateHolder =
rememberSaveable(
saver = PanedSavableStateHolder.Saver
) {
PanedSavableStateHolder()
}.apply {
parentSaveableStateRegistry = LocalSaveableStateRegistry.current
}

private class PanedSavableStateHolder(
private val savedStates: MutableMap<Any, Map<String, List<Any?>>> = mutableMapOf()
) : SaveableStateHolder {
private val registries = mutableScatterMapOf<Any, SaveableStateRegistry>()
var parentSaveableStateRegistry: SaveableStateRegistry? = null
private val canBeSaved: (Any) -> Boolean = {
parentSaveableStateRegistry?.canBeSaved(it) ?: true
}

@Composable
override fun SaveableStateProvider(key: Any, content: @Composable () -> Unit) {
ReusableContent(key) {
val registry = remember {
require(canBeSaved(key)) {
"Type of the key $key is not supported. On Android you can only use types " +
"which can be stored inside the Bundle."
}
// With multiple panes co-existing, its possible for an existing destination
// to have a new registryHolder created in this remember block as it enters
// a new pane before onDispose is called in the DisposableEffect of the old pane,
// yet somehow before the DisposableEffect block that
// calls 'require(key !in registryHolders)' called.

// This makes sure that state is saved a little earlier so the incoming block
registries[key]?.saveTo(savedStates, key)
SaveableStateRegistry(savedStates[key], canBeSaved)
}
CompositionLocalProvider(
LocalSaveableStateRegistry provides registry,
content = content
)
DisposableEffect(Unit) {
require(key !in registries) { "Key $key was used multiple times " }
savedStates -= key
registries[key] = registry
onDispose {
if (registries.remove(key) === registry) {
registry.saveTo(savedStates, key)
}
}
}
}
}

private fun saveAll(): MutableMap<Any, Map<String, List<Any?>>>? {
val map = savedStates
registries.forEach { key, registry -> registry.saveTo(map, key) }
return map.ifEmpty { null }
}

override fun removeState(key: Any) {
if (registries.remove(key) == null) {
savedStates -= key
}
}

private fun SaveableStateRegistry.saveTo(
map: MutableMap<Any, Map<String, List<Any?>>>,
key: Any
) {
val savedData = performSave()
if (savedData.isEmpty()) {
map -= key
} else {
map[key] = savedData
}
}

companion object {
val Saver: Saver<PanedSavableStateHolder, *> =
Saver(save = { it.saveAll() }, restore = { PanedSavableStateHolder(it) })
}
}
Loading