ResultState provides the ability to handle screen results for Compose Navigation3.
Compose Navigation3 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.
- Supporting all platforms that Navigation3 supports.
- Android
- JVM
- Native iOS, watchOS, tvOS
- Native macOS
- Native Linux
- Native Windows
- JS, Wasm JS
Add ResultState dependency to your project.
build.gradle.kts
plugins {
id("com.android.application")
// ...
}
dependencies {
// add ResultState dependency
implementation("io.github.irgaly.navigation3.resultstate:resultstate:1.1.5")
implementation("androidx.navigation3:navigation3-ui:...")
// ...
}build.gradle.kts
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.5")
implementation("org.jetbrains.androidx.navigation3:navigation3-ui:...")
// ...
}
}
}
// ...
}ResultState holds the all results as "String", that is for aiming to easily saved on SavedState architecture.
- The
result keyis just a "String". - The
result valueis just a "String".
So you can produce a result as String with a String result key.
To use ResultState, follow this steps:
- Register the result keys to the consumer screen's NavEntry metadata with
NavigationResultMetadata.resultConsumer()function. - Set
rememberNavigationResultNavEntryDecorator()to NavDisplay's entryDecorators. - Receive the result as
State<NavigationResult?>in the consumer screen byLocalNavigationResultConsumer. - 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.
// Android Compose project sample
interface Screen : NavKey
@Serializable
data object Screen1 : Screen
@Serializable
data object Screen2 : Screen
@Composable
fun NavigationContent() {
@Suppress("UNCHECKED_CAST")
val navBackStack = rememberNavBackStack(Screen1) as NavBackStack<Screen>
val entryProvider = entryProvider<Screen> {
entry<Screen1>(
// 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> {
Screen2(...)
}
}
NavDisplay(
backStack = navBackStack,
onBack = { ... },
entryDecorators = listOf(
// 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,
),
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider,
)
}Next, receive the result as State<NavigationResult?> in Screen1.
@Composable
fun Screen1(...) {
var resultString: String by rememberSaveable { mutableStateOf("{empty}") }
// 3.
// Receive the result as ResultState.
val resultConsumer: NavigationResultConsumer = 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.
@Composable
fun Screen2(...) {
val resultProducer: NavigationResultProducer = 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.
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.
interface Screen : NavKey
@Serializable
data object Screen1 : Screen
@Serializable
data 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<Screen2Result>(
serializer = Screen2Result.serializer(),
resultKey = "Screen2Result",
)
@Composable
fun NavigationContent() {
@Suppress("UNCHECKED_CAST")
val navBackStack = rememberNavBackStack(Screen1) as NavBackStack<Screen>
val entryProvider = entryProvider<Screen> {
entry<Screen1>(
metadata = NavigationResultMetadata.resultConsumer(
// Register Screen2ResultKey as typed key.
Screen2ResultKey,
)
) {
Screen1(...)
}
entry<Screen2> {
Screen2(...)
}
}
NavDisplay(
backStack = navBackStack,
onBack = { ... },
entryDecorators = listOf(
rememberNavigationResultNavEntryDecorator(
backStack = navBackStack,
entryProvider = entryProvider,
),
rememberSaveableStateHolderNavEntryDecorator(),
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: NavigationResultConsumer = LocalNavigationResultConsumer.current
var resultString: String by rememberSaveable { mutableStateOf("{empty}") }
val screen2Result: SerializedNavigationResult<Screen2Result>? by remember(resultConsumer) {
// Pass the json instance and typed key.
resultConsumer.getResultState(json, Screen2ResultKey)
}
LaunchedEffect(screen2Result) {
val result: SerializedNavigationResult<Screen2Result>? = 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: NavigationResultProducer = 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")
}
}
}There are some more code examples.
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.
interface Screen : NavKey
@Serializable
data object Screen1 : Screen
@Serializable
data object Screen2 : Screen
@Serializable
data 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<Screen2Result>(
serializer = Screen2Result.serializer(),
resultKey = "Screen2Result",
)
val Screen3ResultKey = SerializableNavigationResultKey<Screen3Result>(
serializer = Screen3Result.serializer(),
resultKey = "Screen3Result",
)
@Composable
fun NavigationContent() {
@Suppress("UNCHECKED_CAST")
val navBackStack = rememberNavBackStack(Screen1) as NavBackStack<Screen>
val entryProvider = entryProvider<Screen> {
entry<Screen1>(
metadata = NavigationResultMetadata.resultConsumer(
// Screen1 wants to receive a Screen2Result and a Screen3Result.
Screen2ResultKey,
Screen3ResultKey,
)
) {
Screen1(...)
}
entry<Screen2> {
Screen2(...)
}
entry<Screen3> {
Screen3(...)
}
}
NavDisplay(
backStack = navBackStack,
onBack = { ... },
entryDecorators = listOf(
rememberNavigationResultNavEntryDecorator(
backStack = navBackStack,
entryProvider = entryProvider,
),
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider,
)
}
@Composable
fun Screen1(...) {
val json: Json = Json
val resultConsumer: NavigationResultConsumer = LocalNavigationResultConsumer.current
var result2String: String by rememberSaveable { mutableStateOf("{empty}") }
val screen2Result: SerializedNavigationResult<Screen2Result>? by remember(resultConsumer) {
// Receives Screen2Result as State.
resultConsumer.getResultState(json, Screen2ResultKey)
}
LaunchedEffect(screen2Result) {
val result: SerializedNavigationResult<Screen2Result>? = 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<Screen3Result>? by remember(resultConsumer) {
// Receives Screen3Result as State.
resultConsumer.getResultState(json, Screen3ResultKey)
}
LaunchedEffect(screen3Result) {
val result: SerializedNavigationResult<Screen3Result>? = 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: NavigationResultProducer = 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: NavigationResultProducer = 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.
// The example of waiting for both results are produced.
@Composable
fun Screen1(...) {
val json: Json = Json
val resultConsumer: NavigationResultConsumer = LocalNavigationResultConsumer.current
var result2String: String by rememberSaveable { mutableStateOf("{empty}") }
var result3String: String by rememberSaveable { mutableStateOf("{empty}") }
val screen2Result: SerializedNavigationResult<Screen2Result>? by remember(resultConsumer) {
// Receives Screen2Result as State.
resultConsumer.getResultState(json, Screen2ResultKey)
}
val screen3Result: SerializedNavigationResult<Screen3Result>? by remember(resultConsumer) {
// Receives Screen3Result as State.
resultConsumer.getResultState(json, Screen3ResultKey)
}
LaunchedEffect(screen2Result, screen3Result) {
val result2: SerializedNavigationResult<Screen2Result>? = screen2Result
val result3: SerializedNavigationResult<Screen3Result>? = 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")
}
}ResultState will store all results in a MutableState<Map<String, Map<String, String>>>,
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.
@Composable
fun <T : Any> rememberNavigationResultNavEntryDecorator(
backStack: List<T>,
entryProvider: (T) -> NavEntry<*>,
contentKeyToString: (Any) -> String = { it.toString() },
savedStateResults: MutableState<Map<String, Map<String, String>>> = rememberSaveable {
mutableStateOf(emptyMap())
},
): NavEntryDecorator<T> {
val navigationResultStateHolder = rememberNavigationResultStateHolder(
backStack = backStack,
entryProvider = entryProvider,
contentKeyToString = contentKeyToString,
savedStateResults = savedStateResults,
)
return remember(navigationResultStateHolder) {
NavigationResultNavEntryDecorator(navigationResultStateHolder)
}
}The map has the structure below:
Map<String, Map<String, String>>- Key: NavEntry contentKey as String
- Value:
Map<String, String>- Key: a Result Key as String
- Value: a Result as String
So all consumer screens can store the result map on SavedState.
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.
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.
- Screen1's NavEntry contentKey is
The scenario is as follows:
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
The initial state of ResultState map is empty:
| Map Key | Map Value |
|---|---|
| (empty) | (empty) |
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" |
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" |
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" |
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) |
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.
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 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) { ... }.