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

Re-add PanedSavableStateHolder #30

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 2 commits into from
May 5, 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
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@

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
import kotlin.collections.LinkedHashSet

/**
* Wraps the content of a [NavEntry] with a [SaveableStateHolder.SaveableStateProvider] to ensure
Expand All @@ -42,7 +45,7 @@ internal object SaveableStateNavEntryDecorator : NavEntryDecorator {
val localInfo = remember { SaveableStateNavLocalInfo() }
DisposableEffect(key1 = backStack) { onDispose { localInfo.refCount.clear() } }

localInfo.savedStateHolder = rememberSaveableStateHolder()
localInfo.savedStateHolder = rememberPanedSaveableStateHolder()
backStack.forEachIndexed { index, key ->
// We update here as part of composition to ensure the value is available to
// DecorateEntry
Expand Down Expand Up @@ -144,3 +147,87 @@ 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) })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneEntry
import com.tunjid.demo.common.ui.data.NavigationRepository
import com.tunjid.demo.common.ui.data.SampleDestination
import com.tunjid.demo.common.ui.me.mePaneEntry
import com.tunjid.demo.common.ui.avatar.avatarPaneEntry
import com.tunjid.demo.common.ui.profile.profilePaneEntry
import com.tunjid.treenav.MultiStackNav
import com.tunjid.treenav.backStack
Expand Down Expand Up @@ -163,9 +164,10 @@ fun App(
)
},
itemContent = { index ->
val pane = appState.filteredPaneOrder[index]
Destination(pane)
if (pane == ThreePane.Primary) Destination(ThreePane.TransientPrimary)
DragToPopLayout(
state = appState,
pane = appState.filteredPaneOrder[index]
)
}
)
}
Expand Down Expand Up @@ -262,9 +264,11 @@ class AppState(
paneRenderOrder[index + indexDiff]
}
)
internal val dragToPopState = DragToPopState()

internal val isPreviewingBack
get() = !backPreviewState.progress.isNaN()
|| dragToPopState.isDraggingToPop

internal val isMediumScreenWidthOrWider get() = splitLayoutState.size >= SecondaryPaneMinWidthBreakpointDp

Expand Down Expand Up @@ -322,12 +326,10 @@ class AppState(
entryProvider = { destination ->
when (destination) {
SampleDestination.NavTabs.ChatRooms -> chatRoomPaneEntry()

SampleDestination.NavTabs.Me -> mePaneEntry()

is SampleDestination.Chat -> chatPaneEntry()

is SampleDestination.Profile -> profilePaneEntry()
is SampleDestination.Avatar -> avatarPaneEntry()
}
},
transforms = transforms,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright 2024 Adetunji Dahunsi
*
* 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 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.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.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 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.strings.Route

@Stable
internal class DragToPopState {
var isDraggingToPop by mutableStateOf(false)
internal val dragToDismissState = DragToDismissState(
enabled = false,
)
}

@Composable
fun Modifier.dragToPop(): Modifier {
val state = LocalAppState.current.dragToPopState
DisposableEffect(state) {
state.dragToDismissState.enabled = true
onDispose { state.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(),
predicate = {
it != IntOffset.Zero
}
)
return offset { dragToDismissOffset }
}

@Composable
internal fun MultiPaneDisplayScope<ThreePane, SampleDestination>.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
val dismissThreshold = remember { with(density) { 200.dp.toPx().let { it * it } } }

return dragToDismiss(
state = state.dragToPopState.dragToDismissState,
dragThresholdCheck = { offset, _ ->
offset.getDistanceSquared() > dismissThreshold
},
// Enable back preview
onStart = {
state.dragToPopState.isDraggingToPop = true
},
onCancelled = {
// Dismiss back preview
state.dragToPopState.isDraggingToPop = false
},
onDismissed = {
// Dismiss back preview
state.dragToPopState.isDraggingToPop = false

// Pop navigation
state.goBack()
}
)
}

@Composable
private inline fun <T> rememberUpdatedStateIf(
value: T,
predicate: (T) -> Boolean,
): State<T> = remember {
mutableStateOf(value)
}.also { if (predicate(value)) it.value = value }

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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.avatar

import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.ProfilePhoto
import com.tunjid.demo.common.ui.ProfilePhotoArgs
import com.tunjid.demo.common.ui.avatar.Action
import com.tunjid.demo.common.ui.avatar.State
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,
state: State,
onAction: (Action) -> Unit,
modifier: Modifier = Modifier,
) {

Box(
modifier = modifier
.dragToPop()
.fillMaxSize()
) {
val profileName = state.profileName ?: state.profile?.name ?: ""
movableSharedElementScope.updatedMovableSharedElementOf(
key = "${state.roomName}-$profileName",
state = ProfilePhotoArgs(
profileName = profileName,
contentScale = ContentScale.Crop,
contentDescription = null,
),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.aspectRatio(1f),
sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier ->
ProfilePhoto(args, innerModifier)
}
)
}

}
Loading