An open API service indexing awesome lists of open source software.

https://github.com/irgaly/compose-navigation3-resultstate

Compose Navigation3's SavedState Result Library
https://github.com/irgaly/compose-navigation3-resultstate

android compose compose-multiplatform kotlin kotlin-multiplatform

Last synced: 3 months ago
JSON representation

Compose Navigation3's SavedState Result Library

Awesome Lists containing this project

README

          

# Compose Navigation3 ResultState

ResultState provides the ability to handle screen results for Compose Navigation3.

[Compose Navigation3](https://developer.android.com/guide/navigation/navigation-3)
is a great library for navigating with stack data driven screen management, that encourages you
to achieve your feature modules become more clearly separated and independently.

However, Navigation3 lacks a Screen Result handling API at this time.
ResultState provides a Result API based on SavedState architecture for both Android Jetpack Compose and
Compose Multiplatform.

The result values are stored into SavedState, and survive through Activity recreation or process restarting correctly.
Also the saved results are tied to NavEntry's lifecycle, and cleared automatically when the receiver screen is popped out.

# Compatibility in Navigation3 alpha versions

Compose Navigation3 is still in alpha version, so use the Navigation3 version that ResultState is
compatible with.

* ResultState 1.1.2 is compatible with [Navigation3 1.0.0-alpha09](https://developer.android.com/jetpack/androidx/releases/navigation3#1.0.0-alpha09).
* ResultState 1.1.0 is compatible with [Navigation3 1.0.0-alpha08](https://developer.android.com/jetpack/androidx/releases/navigation3#1.0.0-alpha08).

# Supporting Platforms

* Supporting all platforms that Navigation3 supports.
* Android
* JVM
* Native iOS, watchOS, tvOS
* Native macOS
* Native Linux
* Native Windows
* JS, Wasm JS

# Usage

## Setup

Add ResultState dependency to your project.

### for Android Jetpack Compose project

`build.gradle.kts`

```kotlin
plugins {
id("com.android.application")
// ...
}

dependencies {
// add ResultState dependency
implementation("io.github.irgaly.navigation3.resultstate:resultstate:1.1.2")
implementation("androidx.navigation3:navigation3-ui:...")
// ...
}
```

### for Compose Multiplatform project

`build.gradle.kts`

```kotlin
plugins {
kotlin("multiplatform")
id("com.android.application")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
// ...
}

kotlin {
sourceSets {
commonMain {
dependencies {
// add ResultState dependency
implementation("io.github.irgaly.navigation3.resultstate:resultstate:1.1.2")
implementation("org.jetbrains.androidx.navigation3:navigation3-ui:...")
// ...
}
}
}
// ...
}
```

## Using ResultState with NavDisplay

ResultState holds the all results as "String", that is for aiming to easily saved on SavedState
architecture.

* The `result key` is just a "String".
* The `result value` is just a "String".

So you can produce a result as String with a String result key.

To use ResultState, follow this steps:

1. Register the result keys to the consumer screen's NavEntry metadata with
`NavigationResultMetadata.resultConsumer()` function.
2. Set `rememberNavigationResultNavEntryDecorator()` to NavDisplay's entryDecorators.
3. Receive the result as `State` in the consumer screen by
`LocalNavigationResultConsumer`.
4. Produce the result from the producer screen by `LocalNavigationResultProducer`.

Here is an example of an Android Compose project.

Compose Multiplatform project's sample is also available
in [sample/src/commonMain/kotlin/io/github/irgaly/navigation3/resultstate/sample/App.kt](sample/src/commonMain/kotlin/io/github/irgaly/navigation3/resultstate/sample/App.kt).

```kotlin
// Android Compose project sample

@Serializable
sealed interface Screen : NavKey

@Serializable
object Screen1 : Screen

@Serializable
object Screen2 : Screen

@Composable
fun NavigationContent() {
val navBackStack = rememberNavBackStack(Screen1)
val entryProvider = entryProvider {
entry(
// 1.
// Declare that the Screen1 want to receive the Screen2's result, so register "Screen2Result" key to metadata.
// The result key is just unique string tied to the Screen2's result.
metadata = NavigationResultMetadata.resultConsumer(
"Screen2Result",
)
) {
Screen1(...)
}
entry {
Screen2(...)
}
}
NavDisplay(
backStack = navBackStack,
onBack = { ... },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
// 2.
// Set an NavigationResultNavEntryDecorator to NavDisplay.
// This decorator provides LocalNavigationResultProducer and LocalNavigationResultConsumer to NavEntries.
// The entryProvider must be the same one as NavDisplay's entryProvider.
// rememberNavigationResultNavEntryDecorator() will also create NavigationResultStateHolder that holds ResultState on SavedState.
rememberNavigationResultNavEntryDecorator(
backStack = navBackStack,
entryProvider = entryProvider,
),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider,
)
}
```

Next, receive the result as `State` in Screen1.

```kotlin
@Composable
fun Screen1(...) {
var resultString: String by rememberSaveable { mutableStateOf("{empty}") }

// 3.
// Receive the result as ResultState.
val resultConsumer = LocalNavigationResultConsumer.current
val screen2Result: NavigationResult? by remember(resultConsumer) {
// The result key is the same one as registered in Screen1's metadata.
resultConsumer.getResultState("Screen2Result")
}
LaunchedEffect(screen2Result) {
val result: NavigationResult? = screen2Result
if (result != null) {
// NavigationResult.result is just a String.
resultString = result.result
// Clear the result after received to avoid receiving it in next composition.
// Here, result.resultKey is "Screen2Result".
resultConsumer.clearResult(result.resultKey)
}
}
Column {
Text("Screen1")
Text("Received result is: $resultString")
}
}
```

Finally, produce the result from Screen2.

```kotlin
@Composable
fun Screen2(...) {
val resultProducer = LocalNavigationResultProducer.current
Column {
Text("Screen2")
Button(onClick = {
// 4.
// Produce the result for "Screen2Result" key.
// The result key and value are just a String.
resultProducer.setResult(
"Screen2Result",
"my result of screen2!",
)
}) {
Text("Set a result to \"Screen2Result\" key")
}
}
}
```

That's all!

You can receive the Screen2's result "my result of screen2!" from Screen1, when reentered to Screen1
or realtime because of the result is observed by Screen1 as a State.

## Using ResultState with typed result keys and Kotlinx Serialization

ResultState supports to handle the typed result keys and the value as any Serializable type.
Serialization support is provided by extension functions.

Here is an example.

```kotlin
@Serializable
sealed interface Screen : NavKey

@Serializable
object Screen1 : Screen

@Serializable
object Screen2 : Screen

// Declare a serializable result data class.
@Serializable
data class Screen2Result(val result: String)

// Define Screen2's result key as SerializableNavigationResultKey's instance,
// The resultKey is "Screen2Result", and the result type is Screen2Result.
val Screen2ResultKey = SerializableNavigationResultKey(
serializer = Screen2Result.serializer(),
resultKey = "Screen2Result",
)

@Composable
fun NavigationContent() {
val navBackStack = rememberNavBackStack(Screen1)
val entryProvider = entryProvider {
entry(
metadata = NavigationResultMetadata.resultConsumer(
// Register Screen2ResultKey as typed key.
Screen2ResultKey,
)
) {
Screen1(...)
}
entry {
Screen2(...)
}
}
NavDisplay(
backStack = navBackStack,
onBack = { ... },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
backStack = navBackStack,
entryProvider = entryProvider,
),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider,
)
}

@Composable
fun Screen1(...) {
// Use the same Json configuration as Producer side.
// Here, just use a default Json instance for example.
val json: Json = Json
val resultConsumer = LocalNavigationResultConsumer.current
var resultString: String by rememberSaveable { mutableStateOf("{empty}") }
val screen2Result: SerializedNavigationResult? by remember(resultConsumer) {
// Pass the json instance and typed key.
resultConsumer.getResultState(json, Screen2ResultKey)
}
LaunchedEffect(screen2Result) {
val result: SerializedNavigationResult? = screen2Result
if (result != null) {
// The received result is just a String, but getResult() will decode it to a Screen2Result instance.
val screen2Result: Screen2Result = result.getResult()
resultString = screen2Result.result
resultConsumer.clearResult(result.resultKey)
}
}
Column {
Text("Screen1")
Text("Received result is: $resultString")
}
}

@Composable
fun Screen2(...) {
// Use the same Json configuration as Consumer side.
// Here, just use a default Json instance for example.
val json: Json = Json
val resultProducer = LocalNavigationResultProducer.current
Column {
Text("Screen2")
Button(onClick = {
// Pass the json instance, the typed key, and the result instance.
resultProducer.setResult(
json,
Screen2ResultKey,
Screen2Result("my result of screen2!"),
)
}) {
Text("Set a result to Screen2ResultKey")
}
}
}
```

# Code Examples

There are some more code examples.

## Exmaple: The consumer screen receives multiple results

Receiver screen can receive multiple results from multiple producer screens.

Here is an example that assuming:

* Screen1 is a consumer of "Screen2Result" key and "Screen3Result" key.
* Screen2 produces a result of "Screen2Result" key.
* Screen3 produces a result of "Screen3Result" key.
* Using typed result keys and Kotlinx Serialization pattern.

```kotlin
@Serializable
sealed interface Screen : NavKey

@Serializable
object Screen1 : Screen

@Serializable
object Screen2 : Screen

@Serializable
object Screen3 : Screen

// Declare serializable result data classes.
@Serializable
data class Screen2Result(val result: String)
@Serializable
data class Screen3Result(val result: String)

// Define result keys as SerializableNavigationResultKey's instance,
val Screen2ResultKey = SerializableNavigationResultKey(
serializer = Screen2Result.serializer(),
resultKey = "Screen2Result",
)
val Screen3ResultKey = SerializableNavigationResultKey(
serializer = Screen3Result.serializer(),
resultKey = "Screen3Result",
)

@Composable
fun NavigationContent() {
val navBackStack = rememberNavBackStack(Screen1)
val entryProvider = entryProvider {
entry(
metadata = NavigationResultMetadata.resultConsumer(
// Screen1 wants to receive a Screen2Result and a Screen3Result.
Screen2ResultKey,
Screen3ResultKey,
)
) {
Screen1(...)
}
entry {
Screen2(...)
}
entry {
Screen3(...)
}
}
NavDisplay(
backStack = navBackStack,
onBack = { ... },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberNavigationResultNavEntryDecorator(
backStack = navBackStack,
entryProvider = entryProvider,
),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider,
)
}

@Composable
fun Screen1(...) {
val json: Json = Json
val resultConsumer = LocalNavigationResultConsumer.current
var result2String: String by rememberSaveable { mutableStateOf("{empty}") }
val screen2Result: SerializedNavigationResult? by remember(resultConsumer) {
// Receives Screen2Result as State.
resultConsumer.getResultState(json, Screen2ResultKey)
}
LaunchedEffect(screen2Result) {
val result: SerializedNavigationResult? = screen2Result
if (result != null) {
// Receives deserialized Screen2Result instance, and clear it from ResultState.
val screen2Result: Screen2Result = result.getResult()
screen2String = screen2Result.result
resultConsumer.clearResult(result.resultKey)
}
}
var result3String: String by rememberSaveable { mutableStateOf("{empty}") }
val screen3Result: SerializedNavigationResult? by remember(resultConsumer) {
// Receives Screen3Result as State.
resultConsumer.getResultState(json, Screen3ResultKey)
}
LaunchedEffect(screen3Result) {
val result: SerializedNavigationResult? = screen3Result
if (result != null) {
// Receives deserialized Screen3Result instance, and clear it from ResultState.
val screen3Result: Screen3Result = result.getResult()
screen3String = screen3Result.result
resultConsumer.clearResult(result.resultKey)
}
}
Column {
Text("Screen1")
Text("Received Screen2's result is: $result2String")
Text("Received Screen3's result is: $result3String")
}
}

@Composable
fun Screen2(...) {
val json: Json = Json
val resultProducer = LocalNavigationResultProducer.current
Column {
Text("Screen2")
Button(onClick = {
resultProducer.setResult(
json,
Screen2ResultKey,
Screen2Result("my result of screen2!"),
)
}) {
Text("Set a result to Screen2ResultKey")
}
}
}

@Composable
fun Screen3(...) {
val json: Json = Json
val resultProducer = LocalNavigationResultProducer.current
Column {
Text("Screen3")
Button(onClick = {
resultProducer.setResult(
json,
Screen3ResultKey,
Screen3Result("my result of screen3!"),
)
}) {
Text("Set a result to Screen3ResultKey")
}
}
}
```

In this situation, if you'd like to wait for both Screen2Result and Screen3Result are produced,
you can observe both states by single LaunchedEffect. This is an usual Compose way.

```kotlin
// The example of waiting for both results are produced.
@Composable
fun Screen1(...) {
val json: Json = Json
val resultConsumer = LocalNavigationResultConsumer.current
var result2String: String by rememberSaveable { mutableStateOf("{empty}") }
var result3String: String by rememberSaveable { mutableStateOf("{empty}") }
val screen2Result: SerializedNavigationResult? by remember(resultConsumer) {
// Receives Screen2Result as State.
resultConsumer.getResultState(json, Screen2ResultKey)
}
val screen3Result: SerializedNavigationResult? by remember(resultConsumer) {
// Receives Screen3Result as State.
resultConsumer.getResultState(json, Screen3ResultKey)
}
LaunchedEffect(screen2Result, screen3Result) {
val result2: SerializedNavigationResult? = screen2Result
val result3: SerializedNavigationResult? = screen3Result
if (result2 != null && result3 != null) {
// Receives both results, and clear them from ResultState.
val screen2Result: Screen2Result = result2.getResult()
val screen3Result: Screen3Result = result3.getResult()
result2String = screen2Result.result
result3String = screen3Result.result
resultConsumer.clearResult(result2.resultKey)
resultConsumer.clearResult(result3.resultKey)
}
}
Column {
Text("Screen1")
Text("Received Screen2's result is: $result2String")
Text("Received Screen3's result is: $result3String")
}
}
```

# Architecture

ResultState will store all results in a `MutableState>>`,
that is defined in rememberNavigationResultNavEntryDecorator() or
rememberNavigationResultStateHolder() and it is held by NavigationResultStateHolder.

This map contains all values as String, so it can be saved by SavedState.

```kotlin
@Composable
fun rememberNavigationResultNavEntryDecorator(
backStack: List,
entryProvider: (T) -> NavEntry<*>,
contentKeyToString: (Any) -> String = { it.toString() },
savedStateResults: MutableState>> = rememberSaveable {
mutableStateOf(emptyMap())
},
): NavEntryDecorator {
val navigationResultStateHolder = rememberNavigationResultStateHolder(
backStack = backStack,
entryProvider = entryProvider,
contentKeyToString = contentKeyToString,
savedStateResults = savedStateResults,
)
return remember(navigationResultStateHolder) {
NavigationResultNavEntryDecorator(navigationResultStateHolder)
}
}
```

The map has the structure below:

* `Map>`
* Key: NavEntry contentKey as String
* Value: `Map`
* Key: a Result Key as String
* Value: a Result as String

So all consumer screens can store the result map on SavedState.

## Any screens can receive the result

ResultState provides the result to all screens that registered as a consumer by NavEntry's metadata,
so any multiple screens and any position at NavBackStack can consume the result.

This means:

* Assume that:
* The NavBackStack is [Screen1, Screen2, Screen3].
* Then, they are all possible:
* Screen1 receives Screen2's result.
* Screen1 receives Screen3's result.
* Screen2 receives Screen3's result.
* Screen3 receives Screen3's result.

## An example of ResultState lifecycle

The map's contents are associated with NavEntry's lifecycle.

Here is a state's lifecycle example:

* For example, assume that:
* Screen1's NavEntry contentKey is `"screen1"`.
* Screen2's NavEntry contentKey is `"screen2"`.
* Screen3's NavEntry contentKey is `"screen3"`.
* Screen1 is a consumer of `"Screen2Result"` key and `"Screen3Result"` key.
* Screen2 is a consumer of `"Screen3Result"` key.

The scenario is as follows:

```mermaid
sequenceDiagram
participant NavigationResultStateHolder
participant AppNavHost
participant Screen1
participant Screen2
participant Screen3
AppNavHost->>+Screen1: Show
activate Screen1
Screen1->>+Screen2: Navigate
activate Screen2
Screen2->>NavigationResultStateHolder: produce screen2's result
Screen2->>+Screen3: Navigate
activate Screen3
Screen3->>NavigationResultStateHolder: produce screen3's result
Screen3->>Screen2: Back
deactivate Screen3
Screen2->>Screen1: Back
deactivate Screen2
deactivate Screen1
```

### 1. Initial state

The initial state of ResultState map is empty:

| Map Key | Map Value |
|---------|-----------|
| (empty) | (empty) |

### 2. Navigated to Screen2, then Screen2 produce a result

Screen2 produced a result `"result from screen2"` for `"Screen2Result"` key.

The current ResultState map is:

| Map Key | Map Value |
|-------------|----------------------------------------------|
| `"screen1"` | `"Screen2Result"` to `"result from screen2"` |

### 3. Navigated to Screen3, then Screen3 produces a result

Screen3 produced a result `"result from screen3"` for `"Screen3Result"` key.

The current ResultState map is:

| Map Key | Map Value |
|-------------|-----------------------------------------------------------------------------------------------|
| `"screen1"` | `"Screen2Result"` to `"result from screen2"`
`"Screen3Result"` to `"result from screen3"` |
| `"screen2"` | `"Screen3Result"` to `"result from screen3"` |

### 4. Navigated back to Screen2

Navigated back to Screen2, and Screen3 was popped out from the NavBackStack.

Screen3 holds no result in the ResultState map, so the map is not changed.

The current ResultState map is:

| Map Key | Map Value |
|-------------|-----------------------------------------------------------------------------------------------|
| `"screen1"` | `"Screen2Result"` to `"result from screen2"`
`"Screen3Result"` to `"result from screen3"` |
| `"screen2"` | `"Screen3Result"` to `"result from screen3"` |

Then, Screen2 has consumed the `"Screen3Result"` result, and called
`consumer.clearResult("Screen3Result")`.

So the ResultState map is:

| Map Key | Map Value |
|-------------|-----------------------------------------------------------------------------------------------|
| `"screen1"` | `"Screen2Result"` to `"result from screen2"`
`"Screen3Result"` to `"result from screen3"` |

### 5. Navigated back to Screen1

Navigated back to Screen1, and Screen2 was popped out from the NavBackStack.

Screen2 holds no result in the ResultState map, so the map is not changed.

The current ResultState map is:

| Map Key | Map Value |
|-------------|-----------------------------------------------------------------------------------------------|
| `"screen1"` | `"Screen2Result"` to `"result from screen2"`
`"Screen3Result"` to `"result from screen3"` |

Then, Screen1 has consumed the `"Screen2Result"` result and `"Screen3Result"` result, then called
`consumer.clearResult("Screen3Result")`, `consumer.clearResult("Screen3Result")`.

So the ResultState map is:

| Map Key | Map Value |
|---------|-----------|
| (empty) | (empty) |

## The results are cleared when the consumer screen is popped out

When it is navigated to Screen1 from Screen3 by skipping Screen2 showing, Screen2 can not consume
the
`"Screen3Result"` result.

The ResultState map is associated with NavEntry's lifecycle, so the results that the Screen2 holds
are cleared automatically.

```mermaid
sequenceDiagram
participant Screen1
participant Screen2
participant Screen3
activate Screen1
Screen1->>+Screen2: Navigate
activate Screen2
Screen2->>+Screen3: Navigate
activate Screen3
Screen3->>Screen1: Back
deactivate Screen3
deactivate Screen2
deactivate Screen1
```

The Screen3 has showed and produced a result `"result from screen3"` to `"Screen3Result"` key.

The current ResultState map is:

| Map Key | Map Value |
|-------------|-----------------------------------------------------------------------------------------------|
| `"screen1"` | `"Screen2Result"` to `"result from screen2"`
`"Screen3Result"` to `"result from screen3"` |
| `"screen2"` | `"Screen3Result"` to `"result from screen3"` |

Then, it navigated back to Screen1 from Screen3 directly, while the Screen2 was also popped out.

Screen2 did not consume the `"Screen3Result"` result, but the results for `"screen2"` are cleared
automatically.

Then current ResultState map is:

| Map Key | Map Value |
|-------------|-----------------------------------------------------------------------------------------------|
| `"screen1"` | `"Screen2Result"` to `"result from screen2"`
`"Screen3Result"` to `"result from screen3"` |

## ResultState supports multi-pane SceneStrategy

ResultState provides the results as observable State, so the produced results are consumed in
realtime while the consumer screen is showing.

For example, Screen1 and Screen2 are both showing in a multi-pane SceneStrategy, and Screen2
produces a result, then Screen1 can consume the result in realtime by
`LaunchedEffect(resultState) { ... }`.