diff --git a/README.md b/README.md index 6136718..8e7d246 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Why Tiamat? - Easy to use - Allow to pass ANY types as data, even lambdas (!under small condition) - Customizable transitions +- Customizable save-state logic Setup ----- @@ -139,6 +140,7 @@ The scope provides a number of composable functions: - `freeArgs` - free type arguments, useful to store metadata or pass deeplink info - `clearFreeArgs` - clear free type arguments (eg: clear handled deeplink info) - `navResult` - provide the data passed to `NavControllr:back(screen, navResult)` as result +- `clearNavResult` - clear passed nav result (eg: you want to show notification base on result and clear it not to re-show) - `rememberViewModel` - create or provide view model scoped(linked) to current screen - `rememberSharedViewModel` - create or provide view model scoped(linked) to current/provided `NavController` @@ -177,13 +179,13 @@ NavController will keep the screens data, view models, and states during navigat ### Storage mode -- `null` - will take parent NavController mode or `ResetOnDataLoss` for root controller -- `StorageMode.Savable` - will store data in `savable` storage (eg: Android -> Bundle) +- `null` - will take parent NavController mode or `Memory` for root controller +- `StorageMode.SavedState` - will store data in `savable` storage (eg: Android -> Bundle) > [!IMPORTANT] > Only 'Savable' types of params & args will be available to use > > eg: Android - Parcelable + any bundlable primitives -- `StorageMode.ResetOnDataLoss` - store data in memory, allow to use any types of args & params (including lambdas). Reset nav controller upon data loss +- `StorageMode.Memory` - store data in memory, allow to use any types of args & params (including lambdas). Reset nav controller upon data loss ### Known limitations @@ -258,6 +260,7 @@ Custom transition: - [DataPassingResult.kt](example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/DataPassingResult.kt) - How to provide result - [ViewModels.kt](example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/ViewModels.kt) - ViewModels usage - [CustomTransition.kt](example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/CustomTransition.kt) - Custom animations/transition +- [CustomStateSaver.kt](example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/CustomStateSaver.kt) - Custom save/restore state - [Root.kt](example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/multimodule/Root.kt) - Multi-module communication example (using Signals/Broadcast-api) - [BackStackAlteration.kt](example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/BackStackAlteration.kt) - Alteration(modification) of backstack (deeplinks) - [TwoPaneResizableExample.kt](example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/TwoPaneResizableExample.kt) - 2 pane example (list+details, dynamic switch between 1-pane or 2-pane layout) @@ -338,6 +341,14 @@ Web: `./gradlew example:app:composeApp:wasmJsBrowserRun` iOS: run XCode project or else use [KMM](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile) plugin iOS target +## Contributors + +Thank you for your help! ❤️ + + + + + # License ``` diff --git a/example/app/composeApp/src/androidMain/kotlin/composegears/tiamat/example/platform/SavedStateScreen.kt b/example/app/composeApp/src/androidMain/kotlin/composegears/tiamat/example/platform/SavedStateScreen.kt index 0c776a4..0307ca9 100644 --- a/example/app/composeApp/src/androidMain/kotlin/composegears/tiamat/example/platform/SavedStateScreen.kt +++ b/example/app/composeApp/src/androidMain/kotlin/composegears/tiamat/example/platform/SavedStateScreen.kt @@ -30,7 +30,7 @@ val SavedStateScreen by navDestination { Column { TextCaption("In order to test this behaviour (IMPORTANT):") TextCaption("• go ./example/composeApp/src/commonMain/kotlin/App.kt") - TextCaption("• change storageMode to StorageMode.Savable") + TextCaption("• change storageMode to StorageMode.SavedState") TextCaption("• !!WARNING!! other screens may not work due to this changes!!") TextCaption("• compile android app") TextCaption("• go to developer settings of your device") @@ -44,7 +44,7 @@ val SavedStateScreen by navDestination { TextCaption("eg: primitives, parcelable (see: SaveableStateRegistry.canBeSaved)") val savableNavController = rememberNavController( key = "savableNavController", - storageMode = StorageMode.Savable, + storageMode = StorageMode.SavedState, startDestination = SavableDataExampleScreenRoot, destinations = arrayOf( SavableDataExampleScreenRoot, diff --git a/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/App.kt b/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/App.kt index cc74267..18447b0 100644 --- a/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/App.kt +++ b/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/App.kt @@ -27,7 +27,7 @@ fun App( Surface { val rootNavController = rememberNavController( key = "rootNavController", - storageMode = StorageMode.ResetOnDataLoss, + storageMode = StorageMode.Memory, startDestination = MainScreen, destinations = arrayOf( MainScreen, @@ -51,6 +51,7 @@ fun App( CustomTransitionRoot, CustomTransitionScreen1, CustomTransitionScreen2, + CustomStateSaverRoot, MultiModuleRoot, BackStackAlterationRoot, TwoPaneResizableRoot, diff --git a/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/CustomStateSaver.kt b/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/CustomStateSaver.kt new file mode 100644 index 0000000..ce7830c --- /dev/null +++ b/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/CustomStateSaver.kt @@ -0,0 +1,64 @@ +package composegears.tiamat.example + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.composegears.tiamat.Navigation +import com.composegears.tiamat.StorageMode +import com.composegears.tiamat.navDestination +import com.composegears.tiamat.rememberNavController +import composegears.tiamat.example.ui.core.SimpleScreen +import composegears.tiamat.example.ui.core.TextBody +import composegears.tiamat.example.ui.core.TextButton + +val CustomStateSaverRoot by navDestination { + SimpleScreen("Custom state saver") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + var savedState by remember { mutableStateOf>(emptyMap()) } + var showNavController by remember { mutableStateOf(true) } + + TextButton("Toggle navigation -> ${if (showNavController) "Hide" else "Show"}") { + showNavController = !showNavController + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(8.dp) + .border(4.dp, MaterialTheme.colorScheme.onSurface) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + if (showNavController) { + val savableNavController = rememberNavController( + storageMode = StorageMode.Memory, + startDestination = DataPassingParamsRoot, + destinations = arrayOf( + DataPassingParamsRoot, + DataPassingParamsScreen, + ) + ) { + // restore state from custom storage + if (savedState.isNotEmpty()) + loadFromSavedState(savedState) + } + Navigation(navController = savableNavController) + DisposableEffect(Unit) { + onDispose { + // save state into custom storage + savedState = savableNavController.getSavedState() + } + } + } else TextBody("Nothing") + } + } + } +} \ No newline at end of file diff --git a/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/MainScreen.kt b/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/MainScreen.kt index b90d85f..fc6f3fa 100644 --- a/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/MainScreen.kt +++ b/example/app/composeApp/src/commonMain/kotlin/composegears/tiamat/example/MainScreen.kt @@ -31,6 +31,7 @@ val MainScreen by navDestination { "ViewModel/SharedViewModel" to { navController.navigate(ViewModelsRoot) }, "Koin (ViewModel/SharedViewModel)" to { navController.navigate(KoinIntegrationScreen) }, "Custom transition" to { navController.navigate(CustomTransitionRoot) }, + "Custom state saver" to { navController.navigate(CustomStateSaverRoot) }, "Multi-module" to { navController.navigate(MultiModuleRoot) }, "Back stack alteration" to { navController.navigate(BackStackAlterationRoot) }, "2 Pane (list + detail, resizable)" to { navController.navigate(TwoPaneResizableRoot) }, diff --git a/tiamat/src/commonMain/kotlin/com/composegears/tiamat/Compose.kt b/tiamat/src/commonMain/kotlin/com/composegears/tiamat/Compose.kt index d11387b..84934df 100644 --- a/tiamat/src/commonMain/kotlin/com/composegears/tiamat/Compose.kt +++ b/tiamat/src/commonMain/kotlin/com/composegears/tiamat/Compose.kt @@ -19,19 +19,19 @@ enum class StorageMode { /** * Savable storage, persist internal cleanups */ - Savable, + SavedState, /** * In memory data storage, navController will reset on data loss */ - ResetOnDataLoss + Memory } /** * Create and provide [NavController] instance to be used in [Navigation] * * @param key optional key, used to identify NavController's (eg: nc.parent.key == ...) - * @param storageMode data storage mode, default is parent mode or if it is root [StorageMode.ResetOnDataLoss] + * @param storageMode data storage mode, default is parent mode or if it is root [StorageMode] * @param startDestination destination to be used as initial * @param destinations array of allowed destinations for this controller * @param configuration an action to be called after [NavController] created/restored @@ -56,7 +56,7 @@ fun rememberNavController( * Create and provide [NavController] instance to be used in [Navigation] * * @param key optional key, used to identify NavController's (eg: nc.parent.key == ...) - * @param storageMode data storage mode, default is parent mode or if it is root [StorageMode.ResetOnDataLoss] + * @param storageMode data storage mode, default is parent mode or if it is root [StorageMode] * @param startDestination destination to be used as initial * @param startDestinationNavArgs initial destination navArgs * @param startDestinationFreeArgs initial destination freeArgs @@ -68,7 +68,7 @@ fun rememberNavController( fun rememberNavController( key: String? = null, storageMode: StorageMode? = null, - startDestination: NavDestination?, + startDestination: NavDestination, startDestinationNavArgs: T? = null, startDestinationFreeArgs: Any? = null, destinations: Array>, @@ -76,7 +76,7 @@ fun rememberNavController( ) = rememberNavController( key = key, storageMode = storageMode, - startDestination = startDestination?.toNavEntry( + startDestination = startDestination.toNavEntry( navArgs = startDestinationNavArgs, freeArgs = startDestinationFreeArgs ), @@ -88,7 +88,7 @@ fun rememberNavController( * Create and provide [NavController] instance to be used in [Navigation] * * @param key optional key, used to identify NavController's (eg: nc.parent.key == ...) - * @param storageMode data storage mode, default is parent mode or if it is root [StorageMode.ResetOnDataLoss] + * @param storageMode data storage mode, default is parent mode or if it is root [StorageMode] * @param startDestination destination entry (destination + args) to be used as initial * @param destinations array of allowed destinations for this controller * @param configuration an action to be called after [NavController] created/restored @@ -105,7 +105,7 @@ fun rememberNavController( val parent = LocalNavController.current val parentNavEntry = LocalNavEntry.current val navControllersStorage = parentNavEntry?.navControllersStorage ?: rootNavControllersStore() - val finalStorageMode = storageMode ?: parent?.storageMode ?: StorageMode.ResetOnDataLoss + val finalStorageMode = storageMode ?: parent?.storageMode ?: StorageMode.Memory // attach to system save logic and perform model save on it if (parent == null) rememberSaveable( diff --git a/tiamat/src/commonMain/kotlin/com/composegears/tiamat/NavController.kt b/tiamat/src/commonMain/kotlin/com/composegears/tiamat/NavController.kt index da7629c..9a51100 100644 --- a/tiamat/src/commonMain/kotlin/com/composegears/tiamat/NavController.kt +++ b/tiamat/src/commonMain/kotlin/com/composegears/tiamat/NavController.kt @@ -21,7 +21,7 @@ class NavController internal constructor( companion object { private const val KEY_KEY = "key" private const val KEY_STORAGE_MODE = "storageMode" - private const val KEY_NEXT_ENTRY_NAV_ID = "nextEntryNavId" + private const val KEY_PENDING_ENTRY_NAV_ID = "pendingEntryNavId" private const val KEY_START_DESTINATION = "startDestination" private const val KEY_DESTINATIONS = "destinations" private const val KEY_CURRENT = "current" @@ -84,7 +84,7 @@ class NavController internal constructor( internal var contentTransition: ContentTransform? = null private set - private var nextEntryNavId = 0L + private var pendingEntryNavId = 0L init { // ensure there is no same-named destinations @@ -101,7 +101,7 @@ class NavController internal constructor( if (startDestination != null) requireKnownDestination(startDestination.destination) // load from saved state - if (savedState != null) runCatching { + if (!savedState.isNullOrEmpty()) runCatching { restoreFromSavedState(savedState) } // go to start destination if nothing restored @@ -111,7 +111,7 @@ class NavController internal constructor( @Suppress("UNCHECKED_CAST") private fun restoreFromSavedState(savedState: Map) { - nextEntryNavId = savedState[KEY_NEXT_ENTRY_NAV_ID] as Long + pendingEntryNavId = savedState[KEY_PENDING_ENTRY_NAV_ID] as Long val currentNavEntry = (savedState[KEY_CURRENT] as? Map?) ?.let { NavEntry.restore(it, destinations) } (savedState[KEY_BACKSTACK] as List>) @@ -119,16 +119,49 @@ class NavController internal constructor( setCurrentNavEntryInternal(currentNavEntry) } - internal fun saveToSaveState(): Map = mapOf( + private fun getMinimalVerificationSavedState() = mapOf( KEY_KEY to key, KEY_STORAGE_MODE to storageMode.name, - KEY_NEXT_ENTRY_NAV_ID to nextEntryNavId, KEY_START_DESTINATION to startDestination?.destination?.name, KEY_DESTINATIONS to destinations.joinToString(DESTINATIONS_JOIN_SEPARATOR) { it.name }, + ) + + private fun getFullSavedState() = getMinimalVerificationSavedState() + mapOf( + KEY_PENDING_ENTRY_NAV_ID to pendingEntryNavId, KEY_CURRENT to currentNavEntry?.saveToSaveState(), KEY_BACKSTACK to backStack.map { it.saveToSaveState() } ) + internal fun saveToSaveState(): Map = when (storageMode) { + StorageMode.SavedState -> getFullSavedState() + StorageMode.Memory -> getMinimalVerificationSavedState() + } + + /** + * Save current navController state(full, regardless of `storageMode`) + * and it's children states(depend on theirs `storageMode`) + * + * @return saved state + */ + fun getSavedState(): Map = getFullSavedState() + + /** + * Load navController (and it's children) state from saved state + * + * Use with caution, calling this method will reset backstack and current entry + * + * @param savedState saved state + */ + fun loadFromSavedState(savedState: Map) { + // clear current state + close() + // load from saved state + restoreFromSavedState(savedState) + // navigate to start destination if nothing restored + if (currentNavEntry == null && backStack.isEmpty() && startDestination != null) + setCurrentNavEntryInternal(NavEntry(startDestination)) + } + /** * @param key nav controller's key to search for * @@ -152,7 +185,7 @@ class NavController internal constructor( private fun setCurrentNavEntryInternal( navEntry: NavEntry<*>?, ) { - if (navEntry != null && navEntry.navId < 0) navEntry.navId = nextEntryNavId++ + if (navEntry != null && navEntry.navId < 0) navEntry.navId = pendingEntryNavId++ currentNavEntry = navEntry current = navEntry?.destination pendingBackTransition = null