From 441aa1dc1c97ced4afd2ebb1886728196383f042 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 4 May 2025 23:51:41 -0400 Subject: [PATCH 1/2] Add an avatar screen for drag to dismiss testing --- .../com/tunjid/demo/common/ui/DemoApp.kt | 14 +- .../com/tunjid/demo/common/ui/DragToPop.kt | 122 ++++++++++++++++++ .../demo/common/ui/avatar/AvatarScreen.kt | 68 ++++++++++ .../demo/common/ui/avatar/AvatarViewModel.kt | 85 ++++++++++++ .../tunjid/demo/common/ui/avatar/PaneEntry.kt | 63 +++++++++ .../common/ui/data/NavigationRepository.kt | 15 +++ .../demo/common/ui/profile/ProfileScreen.kt | 6 + .../common/ui/profile/ProfileViewModel.kt | 10 ++ 8 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt create mode 100644 sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt create mode 100644 sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt create mode 100644 sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt 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 9a54869..8500e39 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 @@ -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 @@ -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] + ) } ) } @@ -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 @@ -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, 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 new file mode 100644 index 0000000..c0b2783 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt @@ -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.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 rememberUpdatedStateIf( + value: T, + predicate: (T) -> Boolean, +): State = remember { + mutableStateOf(value) +}.also { if (predicate(value)) it.value = value } + 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 new file mode 100644 index 0000000..f6d9078 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt @@ -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) + } + ) + } + +} \ 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 new file mode 100644 index 0000000..8f8ccc9 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt @@ -0,0 +1,85 @@ +/* + * 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.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ViewModel +import com.tunjid.demo.common.ui.data.NavigationAction +import com.tunjid.demo.common.ui.data.NavigationRepository +import com.tunjid.demo.common.ui.data.Profile +import com.tunjid.demo.common.ui.data.ProfileRepository +import com.tunjid.demo.common.ui.data.navigationAction +import com.tunjid.demo.common.ui.data.navigationMutations +import com.tunjid.mutator.ActionStateMutator +import com.tunjid.mutator.Mutation +import com.tunjid.mutator.coroutines.actionStateFlowMutator +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.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +class AvatarViewModel( + coroutineScope: LifecycleCoroutineScope, + profileRepository: ProfileRepository = ProfileRepository, + navigationRepository: NavigationRepository = NavigationRepository, + profileName: String?, + roomName: String?, +) : ViewModel(coroutineScope), + ActionStateMutator> by coroutineScope.actionStateFlowMutator( + initialState = State( + roomName = roomName, + profileName = profileName, + ), + inputs = listOf( + profileRepository.profileMutations(profileName) + ), + actionTransform = { actions -> + actions.toMutationStream( + keySelector = Action::key + ) { + when (val type = type()) { + is Action.Navigation -> navigationRepository.navigationMutations( + type.flow + ) + } + } + } + ) + +private fun ProfileRepository.profileMutations( + profileName: String?, +): Flow> = + (profileName?.let(::profileFor) ?: me) + .mapToMutation { copy(profile = it) } + +data class State( + val roomName: String? = null, + val profileName: String? = null, + val profile: Profile? = null, +) + +sealed class Action( + val key: String, +) { + sealed class Navigation : Action("Navigation"), NavigationAction { + data object Pop : Navigation(), NavigationAction by navigationAction( + MultiStackNav::pop + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..632ce0e --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt @@ -0,0 +1,63 @@ +/* + * 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.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.PaneScaffold +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs +import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.threepane.threePaneEntry + +fun avatarPaneEntry() = threePaneEntry( + paneMapping = { destination -> + check(destination is SampleDestination.Avatar) + mapOf( + ThreePane.Primary to destination, + ThreePane.Secondary to destination.roomName?.let(SampleDestination::Chat), + ThreePane.Tertiary to destination.roomName?.let { NavTabs.ChatRooms }, + ) + }, + render = { destination -> + check(destination is SampleDestination.Avatar) + val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope + val viewModel = viewModel { + AvatarViewModel( + coroutineScope = scope, + profileName = destination.profileName, + roomName = destination.roomName, + ) + } + PaneScaffold( + modifier = Modifier + .fillMaxSize(), + content = { + AvatarScreen( + movableSharedElementScope = this, + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept, + modifier = Modifier.fillMaxSize() + ) + }, + ) + }, +) \ 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 bed375d..a45bbb6 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 @@ -72,6 +72,21 @@ sealed interface SampleDestination : Node { roomName?.let { NavTabs.ChatRooms } ) } + + data class Avatar( + val profileName: String, + val roomName: String?, + ) : SampleDestination { + + override val id: String + get() = "avatar-$profileName-$roomName" + + override val children: List + get() = listOfNotNull( + roomName?.let(::Chat), + roomName?.let { NavTabs.ChatRooms } + ) + } } fun interface NavigationAction { 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 e5a946f..55830cb 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 @@ -17,6 +17,7 @@ package com.tunjid.demo.common.ui.profile import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -75,6 +76,11 @@ fun ProfileScreen( y = -headerState.translation.roundToInt() ) } + .clickable { + state.profileName ?: return@clickable + state.roomName ?: return@clickable + onAction(Action.Navigation.ToAvatar(state.profileName, state.roomName)) + } ) }, body = { 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 33f1067..a2ec738 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 @@ -18,10 +18,12 @@ 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 import com.tunjid.demo.common.ui.data.ProfileRepository +import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.navigationAction import com.tunjid.demo.common.ui.data.navigationMutations import com.tunjid.mutator.ActionStateMutator @@ -31,6 +33,7 @@ import com.tunjid.mutator.coroutines.mapToMutation import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop +import com.tunjid.treenav.push import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -81,5 +84,12 @@ sealed class Action( data object Pop : Navigation(), NavigationAction by navigationAction( MultiStackNav::pop ) + + data class ToAvatar( + val profileName: String, + val roomName: String, + ) : Navigation(), NavigationAction by navigationAction( + { push(SampleDestination.Avatar(profileName = profileName, roomName = roomName)) } + ) } } \ No newline at end of file From 1305a5a2732f8f507357d9404e3271c2b9f4de46 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 5 May 2025 00:16:12 -0400 Subject: [PATCH 2/2] Readd PanedSavableStateHolder --- .../SaveableStateNavEntryDecorator.kt | 93 ++++++++++++++++++- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt index 36d4644..5e92cea 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SaveableStateNavEntryDecorator.kt @@ -16,17 +16,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 @@ -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 @@ -144,3 +147,87 @@ internal class SaveableStateNavLocalInfo { @Suppress("PrimitiveInCollection") // The order of the element matters internal val idsInComposition: LinkedHashSet = LinkedHashSet() } + +@Composable +internal fun rememberPanedSaveableStateHolder(): SaveableStateHolder = + rememberSaveable( + saver = PanedSavableStateHolder.Saver + ) { + PanedSavableStateHolder() + }.apply { + parentSaveableStateRegistry = LocalSaveableStateRegistry.current + } + +private class PanedSavableStateHolder( + private val savedStates: MutableMap>> = mutableMapOf() +) : SaveableStateHolder { + private val registries = mutableScatterMapOf() + var parentSaveableStateRegistry: SaveableStateRegistry? = null + private val canBeSaved: (Any) -> Boolean = { + parentSaveableStateRegistry?.canBeSaved(it) ?: true + } + + @Composable + override fun SaveableStateProvider(key: Any, content: @Composable () -> Unit) { + ReusableContent(key) { + val registry = remember { + require(canBeSaved(key)) { + "Type of the key $key is not supported. On Android you can only use types " + + "which can be stored inside the Bundle." + } + // With multiple panes co-existing, its possible for an existing destination + // to have a new registryHolder created in this remember block as it enters + // a new pane before onDispose is called in the DisposableEffect of the old pane, + // yet somehow before the DisposableEffect block that + // calls 'require(key !in registryHolders)' called. + + // This makes sure that state is saved a little earlier so the incoming block + registries[key]?.saveTo(savedStates, key) + SaveableStateRegistry(savedStates[key], canBeSaved) + } + CompositionLocalProvider( + LocalSaveableStateRegistry provides registry, + content = content + ) + DisposableEffect(Unit) { + require(key !in registries) { "Key $key was used multiple times " } + savedStates -= key + registries[key] = registry + onDispose { + if (registries.remove(key) === registry) { + registry.saveTo(savedStates, key) + } + } + } + } + } + + private fun saveAll(): MutableMap>>? { + val map = savedStates + registries.forEach { key, registry -> registry.saveTo(map, key) } + return map.ifEmpty { null } + } + + override fun removeState(key: Any) { + if (registries.remove(key) == null) { + savedStates -= key + } + } + + private fun SaveableStateRegistry.saveTo( + map: MutableMap>>, + key: Any + ) { + val savedData = performSave() + if (savedData.isEmpty()) { + map -= key + } else { + map[key] = savedData + } + } + + companion object { + val Saver: Saver = + Saver(save = { it.saveAll() }, restore = { PanedSavableStateHolder(it) }) + } +}