diff --git a/core/src/main/kotlin/com/kiwi/navigationcompose/typed/ResultDestination.kt b/core/src/main/kotlin/com/kiwi/navigationcompose/typed/ResultDestination.kt new file mode 100644 index 0000000..b3ab55e --- /dev/null +++ b/core/src/main/kotlin/com/kiwi/navigationcompose/typed/ResultDestination.kt @@ -0,0 +1,25 @@ +package com.kiwi.navigationcompose.typed + +/** + * Marks the destinations as destination being able to return a result. + * + * The result type is defined as the generic argument if this interface and has to be KotlinX Serializable. + * + * Use [ComposableResultEffect] or [DialogResultEffect] to observe the result. + * Use [setResult] to send the result from a destination. + * + * ``` + * sealed interface Destinations : Destination { + * // ... + * @Serializable + * object Dialog : Destinations, ResultDestination { + * @Serializable + * data class Result(val name: String) + * } + * } + * ``` + * + * Opening the same ResultDestination multiple times per screen has to be distinguished by manually sending an + * identifier to the result destination and this identifier has to be propagated back by the result type. + */ +public interface ResultDestination : Destination diff --git a/core/src/main/kotlin/com/kiwi/navigationcompose/typed/ResultSharing.kt b/core/src/main/kotlin/com/kiwi/navigationcompose/typed/ResultSharing.kt new file mode 100644 index 0000000..8634131 --- /dev/null +++ b/core/src/main/kotlin/com/kiwi/navigationcompose/typed/ResultSharing.kt @@ -0,0 +1,126 @@ +package com.kiwi.navigationcompose.typed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavController +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + +/** + * Registers an effect for processing a composable destination's result. See [ResultDestination]. + * + * Use only to process a result from "full" destination created by `composable` NavGraph builder function. + * If you are expecting a result from dialog or full-screen dialog, [DialogResultEffect] has to be used + * to properly handle configuration changes. + * + * To avoid specifying generic argument types, use type-inference and type the result argument of the block: + * ``` + * ComposableResultEffect(navController) { result: Destinations.Dialog.Result -> + * // handle result + * } + * ``` + */ +@Suppress("unused") // T generic parameter is a typecheck for R being the type from ResultDestination +@ExperimentalSerializationApi +@Composable +public inline fun , reified R : Any> ComposableResultEffect( + navController: NavController, + noinline block: (R) -> Unit, +) { + @Suppress("RemoveExplicitTypeArguments") + ResultEffectImpl( + navController = navController, + currentRoute = navController.currentDestination!!.route!!, + resultSerializer = serializer(), + block = block, + ) +} + +/** + * Registers an effect for processing a dialog destination's result. See [ResultDestination]. + * + * Use only to process a result from dialog (and full-screen dialog) destination created by `dialog` NavGraph + * builder function. + * + * To avoid specifying generic argument types, use type-inference and type the result argument of the block: + * ``` + * DialogResultEffect( + * currentRoutePattern = createRoutePattern(), + * navController = navController, + * ) { result: Destinations.Dialog.Result -> + * ``` + */ +@Suppress("unused") // T generic parameter is the typecheck for R being the type from ResultDestination +@ExperimentalSerializationApi +@Composable +public inline fun , reified R : Any> DialogResultEffect( + currentRoutePattern: String, + navController: NavController, + noinline block: (R) -> Unit, +) { + @Suppress("RemoveExplicitTypeArguments") + ResultEffectImpl( + navController = navController, + currentRoute = currentRoutePattern, + resultSerializer = serializer(), + block = block, + ) +} + +/** + * Implementation of ResultEffect. Use [ComposableResultEffect] or [DialogResultEffect] directly. + */ +@ExperimentalSerializationApi +@Composable +public fun ResultEffectImpl( + navController: NavController, + currentRoute: String, + resultSerializer: KSerializer, + block: (R) -> Unit, +) { + DisposableEffect(navController, block) { + // The implementation is based on the official documentation of the Result sharing. + // It takes into consideration the possibility of a dialog usage (see the docs). + // https://developer.android.com/guide/navigation/navigation-programmatic#additional_considerations + val resultKey = resultSerializer.descriptor.serialName + "_result" + val backStackEntry = navController.getBackStackEntry(currentRoute) + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && backStackEntry.savedStateHandle.contains(resultKey)) { + val result = backStackEntry.savedStateHandle.remove(resultKey)!! + val decoded = Json.decodeFromString(resultSerializer, result) + block(decoded) + } + } + backStackEntry.lifecycle.addObserver(observer) + onDispose { + backStackEntry.lifecycle.removeObserver(observer) + } + } +} + +/** + * Sets a destination result for the previous backstack entry's destination. + * + * The result type has to be KotlinX Serializable. + */ +@ExperimentalSerializationApi +@Suppress("unused") // T generic parameter is the typecheck for R being the type from ResultDestination +public inline fun , reified R : Any> NavController.setResult( + data: R, +) { + setResultImpl(serializer(), data) +} + +@ExperimentalSerializationApi +public fun NavController.setResultImpl( + serializer: KSerializer, + data: R, +) { + val result = Json.encodeToString(serializer, data) + val resultKey = serializer.descriptor.serialName + "_result" + previousBackStackEntry?.savedStateHandle?.set(resultKey, result) +} diff --git a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/Destinations.kt b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/Destinations.kt index d79d48e..742e071 100644 --- a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/Destinations.kt +++ b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/Destinations.kt @@ -1,6 +1,7 @@ package com.kiwi.navigationcompose.typed.demo import com.kiwi.navigationcompose.typed.Destination +import com.kiwi.navigationcompose.typed.ResultDestination import kotlinx.serialization.Serializable internal sealed interface Destinations : Destination { @@ -30,3 +31,20 @@ internal sealed interface HomeDestinations : Destination { val stringNullableOptional: String? = null, ) : HomeDestinations } + +internal sealed interface ProfileDestinations : Destination { + @Serializable + object Home : ProfileDestinations + + @Serializable + object NameEditDialog : ProfileDestinations, ResultDestination { + @Serializable + data class Result(val name: String) + } + + @Serializable + object NameEditScreen : ProfileDestinations, ResultDestination { + @Serializable + data class Result(val name: String) + } +} diff --git a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/NavHost.kt b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/NavHost.kt index c03002f..a30ad67 100644 --- a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/NavHost.kt +++ b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/NavHost.kt @@ -8,7 +8,10 @@ import com.kiwi.navigationcompose.typed.createRoutePattern import com.kiwi.navigationcompose.typed.demo.screens.Demo import com.kiwi.navigationcompose.typed.demo.screens.Home import com.kiwi.navigationcompose.typed.demo.screens.List +import com.kiwi.navigationcompose.typed.demo.screens.NameEditDialog +import com.kiwi.navigationcompose.typed.demo.screens.NameEditScreen import com.kiwi.navigationcompose.typed.demo.screens.Profile +import com.kiwi.navigationcompose.typed.dialog import com.kiwi.navigationcompose.typed.navigate import com.kiwi.navigationcompose.typed.navigation import kotlinx.serialization.ExperimentalSerializationApi @@ -27,6 +30,12 @@ internal fun NavHost(navController: NavHostController) { composable { Demo(this, navController::navigateUp) } } composable { List() } - composable { Profile() } + navigation( + startDestination = createRoutePattern(), + ) { + composable { Profile(navController) } + dialog { NameEditDialog(navController) } + composable { NameEditScreen(navController) } + } } } diff --git a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/NameEditDialog.kt b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/NameEditDialog.kt new file mode 100644 index 0000000..53da53e --- /dev/null +++ b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/NameEditDialog.kt @@ -0,0 +1,50 @@ +package com.kiwi.navigationcompose.typed.demo.screens + +import androidx.compose.material.AlertDialog +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.navigation.NavController +import com.kiwi.navigationcompose.typed.demo.ProfileDestinations +import com.kiwi.navigationcompose.typed.setResult +import kotlinx.serialization.ExperimentalSerializationApi + +@ExperimentalSerializationApi +@Composable +internal fun NameEditDialog(navController: NavController) { + NameEdit( + onNameSave = { name -> + navController.setResult(ProfileDestinations.NameEditDialog.Result(name)) + navController.navigateUp() + }, + onDismiss = navController::navigateUp, + ) +} + +@Composable +private fun NameEdit( + onNameSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Set your name") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + ) + }, + confirmButton = { + TextButton(onClick = { onNameSave(name) }) { + Text("Save") + } + }, + ) +} diff --git a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/NameEditScreen.kt b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/NameEditScreen.kt new file mode 100644 index 0000000..c11a201 --- /dev/null +++ b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/NameEditScreen.kt @@ -0,0 +1,43 @@ +package com.kiwi.navigationcompose.typed.demo.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.navigation.NavController +import com.kiwi.navigationcompose.typed.demo.ProfileDestinations +import com.kiwi.navigationcompose.typed.setResult +import kotlinx.serialization.ExperimentalSerializationApi + +@ExperimentalSerializationApi +@Composable +internal fun NameEditScreen(navController: NavController) { + NameEdit( + onNameSave = { name -> + navController.setResult(ProfileDestinations.NameEditScreen.Result(name)) + navController.navigateUp() + }, + ) +} + +@Composable +private fun NameEdit( + onNameSave: (String) -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + Column { + Text("Set your name") + OutlinedTextField( + value = name, + onValueChange = { name = it }, + ) + TextButton(onClick = { onNameSave(name) }) { + Text("Save") + } + } +} diff --git a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/Profile.kt b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/Profile.kt index 063154e..80ff3b1 100644 --- a/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/Profile.kt +++ b/demo/src/main/kotlin/com/kiwi/navigationcompose/typed/demo/screens/Profile.kt @@ -1,9 +1,72 @@ package com.kiwi.navigationcompose.typed.demo.screens +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import com.kiwi.navigationcompose.typed.ComposableResultEffect +import com.kiwi.navigationcompose.typed.Destination +import com.kiwi.navigationcompose.typed.DialogResultEffect +import com.kiwi.navigationcompose.typed.createRoutePattern +import com.kiwi.navigationcompose.typed.demo.ProfileDestinations +import com.kiwi.navigationcompose.typed.navigate +import kotlinx.serialization.ExperimentalSerializationApi +@ExperimentalSerializationApi @Composable -internal fun Profile() { - Text("Profile") +internal fun Profile(navController: NavController) { + var name by rememberSaveable { mutableStateOf("") } + var times by rememberSaveable { mutableStateOf(0) } + + DialogResultEffect( + currentRoutePattern = createRoutePattern(), + navController = navController, + ) { result: ProfileDestinations.NameEditDialog.Result -> + name = "Dialog: ${result.name}" + times += 1 + } + ComposableResultEffect(navController) { result: ProfileDestinations.NameEditScreen.Result -> + name = "Screen: ${result.name}" + times += 1 + } + + Profile(name, times, navController::navigate) +} + +@Composable +private fun Profile( + name: String, + times: Int, + onNavigate: (Destination) -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .wrapContentSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Profile") + + Text("Name: $name, changed: $times") + + OutlinedButton( + onClick = { onNavigate(ProfileDestinations.NameEditDialog) }, + ) { + Text("Change Name Dialog") + } + OutlinedButton( + onClick = { onNavigate(ProfileDestinations.NameEditScreen) }, + ) { + Text("Change Name Screen") + } + } } diff --git a/readme.md b/readme.md index 5d9ad9d..98695a7 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ Navigation Compose Typed ======================== -Compile-time type-safe arguments for Jetpack Navigation Compose library. Based on KotlinX.Serialization. +Compile-time type-safe arguments for the Jetpack Navigation Compose library. Based on KotlinX.Serialization. [![Kiwi.com library](https://img.shields.io/badge/Kiwi.com-library-00A991)](https://code.kiwi.com) [![CI Build](https://img.shields.io/github/workflow/status/kiwicom/navigation-compose-typed/Build/main)](https://github.com/kiwicom/navigation-compose-typed/actions/workflows/build.yml) @@ -10,8 +10,8 @@ Compile-time type-safe arguments for Jetpack Navigation Compose library. Based o Major features: -- Complex types support, including nullability for primitive types - the only condition is that the type has to be serializable with KotlinX.Serializable library. -- Based on official Kotlin Serialization compiler plugin - no slowdown with KSP nor KAPT. +- Complex types' support, including nullability for primitive types - the only condition is that the type has to be serializable with KotlinX.Serializable library. +- Based on official Kotlin Serialization compiler plugin - no slowdown with KSP or KAPT. - All Jetpack Navigation Compose features: e.g. `navigateUp()` after a deeplink preserves the top-level shared arguments. - Few simple functions, no new complex `NavHost` or `NavController` types; this allows covering other Jetpack Navigation Compose extensions. - Gradual integration, feel free to onboard just a part of your app. @@ -109,7 +109,7 @@ private fun Home( ### Extensibility -What about cooperation with Accompanist's `AnimatedNavHost` or `bottomSheet {}`? Do not worry. Basically, all this are just few simple functions. Create your own abstraction and use `createRoutePattern()`, `createNavArguments()`, `decodeArguments()` and `registerDestinationType()` functions. +What about cooperation with Accompanist's `AnimatedNavHost` or `bottomSheet {}`? Do not worry. Basically, all these are just a few simple functions. Create your own abstraction and use `createRoutePattern()`, `createNavArguments()`, `decodeArguments()` and `registerDestinationType()` functions. ```kotlin import com.kiwi.navigationcompose.typed.createRoutePattern @@ -138,3 +138,37 @@ NavGraph { } } ``` + +### Result sharing + +Another set of functionality is provided to support the result sharing. First, define the destination as `ResultDestination` type and specify the result type class. Then open the screen as usual and utilize `ComposableResultEffect` or `DialogResultEffect` to observe the destination's result. To send the result, use +`NavController`'s extension `setResult`. + +```kotlin +import com.kiwi.navigationcompose.typed.Destination +import com.kiwi.navigationcompose.typed.ResultDestination + +sealed interface Destinations : Destination { + @Serializable + object Dialog : Destinations, ResultDestination { + @Serializable + data class Result( + val something: Int, + ) + } +} + +@Composable +fun Host(navController: NavController) { + ComposableResultEffect(navController) { result: Destinations.Dialog.Result -> + println(result) + // process the result + } + + Button( + onClick = { bavController.navigate(Destinations.Dialog) }, + ) { + Text("Open") + } +} +```