Q: How to return a result to a previous component? #201
-
Hello! And then use a combo of a Reading the docs at Decompose, MVIKotlin and now Essenty, I think with Essenty the closest multiplatform equivalent would be StateKeeper? To try out the idea, I took my Decompose Navigation Sample, updated it locally to use newly released Decompose v0.3.0 and Essenty v0.1.1 and modified one of the codepaths: Note: (Napier is just a logging lib I use) The changes: ScreenAComponent (unchanged) class ScreenAComponent(
componentContext: ComponentContext
) : IScreenA, ComponentContext by componentContext {
private val router =
router<Config, IScreenA.Child>(
initialConfiguration = Config.ScreenA1,
handleBackButton = true,
childFactory = ::createChild
)
override val routerState: Value<RouterState<*, IScreenA.Child>> = router.state
private fun createChild(config: Config, componentContext: ComponentContext): IScreenA.Child =
when (config) {
is Config.ScreenA1 -> IScreenA.Child.ScreenA1(screenA1(componentContext))
is Config.ScreenA2 -> IScreenA.Child.ScreenA2(screenA2(componentContext))
}
private fun screenA1(componentContext: ComponentContext): IScreenA1 =
ScreenA1Component(componentContext) {
router.push(Config.ScreenA2)
}
private fun screenA2(componentContext: ComponentContext): IScreenA2 =
ScreenA2Component(componentContext)
private sealed class Config : Parcelable {
@Parcelize
object ScreenA1 : Config()
@Parcelize
object ScreenA2 : Config()
}
} ScreenA1Component (display the value stored in the statekeeper) class ScreenA1Component(
private val componentContext: ComponentContext,
private val navigateToA2: () -> Unit
) : IScreenA1, ComponentContext by componentContext {
init {
lifecycle.doOnResume {
Napier.d("onResume")
val state: ScreenA2Component.State? = stateKeeper.consume("A2_RESULT")
Napier.d("A2_RESULT - myValue: ${state?.myValue}")
}
}
override fun navigateToA2Clicked() {
navigateToA2()
}
} ScreenA2Component (initialized and display the value stored in the statekeeper) class ScreenA2Component (
private val componentContext: ComponentContext,
) : IScreenA2, ComponentContext by componentContext {
private var state: State = stateKeeper.consume("SAVED_STATE") ?: State()
init {
stateKeeper.register("SAVED_STATE") { state }
lifecycle.doOnCreate {
Napier.d("onCreate")
}
lifecycle.doOnResume {
Napier.d("onResume")
Napier.d("A2_RESULT - myValue: ${state.myValue}")
}
}
@Parcelize
class State(
val myValue: Int = 1234
) : Parcelable
} Trying out the changes and logging the interactions, launching the app:
Null is expected Pressed Button to go to A2
1234 is expected as the state has now been set Pressed Back Button to go back to A1
Hmm, I did not expected this. What am I doing wrong? |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 2 replies
-
The Regarding the way of handling results recommended by Google. Personally, I really don't like. For example consider the following snippet demonstrating how we should send results from navController.previousBackStackEntry?.savedStateHandle?.set("key", result) What issues do I see in this approach:
I recommend to deliver results explicitly. Here is a very rough example of how it can be done: class Main(
private val onShowDetails: () -> Unit
) {
// Call onShowDetails when needed
fun onListResult(value: Int) {
// Handle the result
}
}
class Details(
private val onFinished: (result: Int) -> Unit
) {
// Call onFinished with a result when done
}
class Root(componentContext: ComponentContext): ComponentContext by componentContext {
private val router =
router<Config, Any>(
initialConfiguration = Config.Main,
childFactory = ::child
)
private fun child(config: Config, componentContext: ComponentContext): Any =
when (config) {
is Config.Main -> Main(onShowDetails = { router.push(Config.Details) })
is Config.Details ->
Details(
onFinished = { result ->
router.pop()
(router.state.value.activeChild.instance as Main).onListResult(result) // Deliver the result
}
)
}
@Parcelize
sealed class Config : Parcelable {
object Main : Config()
object Details : Config()
}
} Or we can use the power of Reaktive library (or coroutines in a similar way): class Main(
componentContext: ComponentContext,
input: Observable<Input>,
output: (Output) -> Unit
) : ComponentContext by componentContext, DisposableScope by DisposableScope() {
init {
input.subscribeScoped(onNext = ::onInput)
lifecycle.doOnDestroy(::dispose)
}
private fun onInput(input: Input) {
when (input) {
is Input.ListResult -> TODO("Handle the result")
}.let {}
}
sealed class Output {
object ShowDetails : Output()
}
sealed class Input {
data class ListResult(val value: Int) : Input()
}
}
class Details(
private val output: (Output) -> Unit
) {
// Send Output.Finished with a result when done
sealed class Output {
data class Finished(val result: Int) : Output()
}
}
class Root(componentContext: ComponentContext) : ComponentContext by componentContext {
private val router =
router<Config, Any>(
initialConfiguration = Config.Main,
childFactory = ::child
)
private val mainInput = PublishSubject<Main.Input>()
private fun child(config: Config, componentContext: ComponentContext): Any =
when (config) {
is Config.Main -> Main(componentContext, input = mainInput, output = ::onMainOutput)
is Config.Details -> Details(output = ::onDetailsOutput)
}
private fun onMainOutput(input: Main.Output) {
when (input) {
is Main.Output.ShowDetails -> router.push(Config.Details)
}
}
private fun onDetailsOutput(output: Details.Output) {
when (output) {
is Details.Output.Finished -> {
router.pop()
mainInput.invoke(Main.Input.ListResult(value = output.result))
}
}.let {}
}
@Parcelize
sealed class Config : Parcelable {
object Main : Config()
object Details : Config()
}
} Please note, if you also need to deliver a result when the hardware back button is pressed, then you should use There are likely more possible solutions, so you can find what works better for you. |
Beta Was this translation helpful? Give feedback.
-
Thanks for the help and thoughts! Looks like I made a mistake while copying my sample code to the question. The sample should have been like this where it matches the keys: ScreenA1Component val state: ScreenA2Component.State? = stateKeeper.consume("A2_RESULT") ScreenA2Component private var state: State = stateKeeper.consume("A2_RESULT") ?: State() However, reading your explanation, it does make sense why it didn't work since the Following your advice I implemented it in the Decompose KMM Navigation Sample with the Solution 1 - If the router is not handling the back buttonclass ScreenC1Component(
private val componentContext: ComponentContext,
private val navigateToC2: () -> Unit
) : IScreenC1, ComponentContext by componentContext {
private val _model = MutableValue(Model(magicNumber = 0))
override val model: Value<Model> = _model
override fun navigateToC2Clicked() {
navigateToC2()
}
override fun onResult(value: Int) {
_model.reduce { it.copy(magicNumber = value) }
}
}
class ScreenC2Component (
private val componentContext: ComponentContext,
private val onFinished: (result: Int) -> Unit
) : IScreenC2, ComponentContext by componentContext {
init {
backPressedDispatcher.register(::onBackPressed)
}
private fun onBackPressed(): Boolean {
// Return a result to the previous component
onFinished(42)
// Return true to consume the event
return true
}
}
class ScreenCComponent(
componentContext: ComponentContext
) : IScreenC, ComponentContext by componentContext {
private val router =
router<Config, IScreenC.Child>(
initialConfiguration = Config.ScreenC1,
handleBackButton = false,
childFactory = ::createChild
)
override val routerState: Value<RouterState<*, IScreenC.Child>> = router.state
private fun createChild(config: Config, componentContext: ComponentContext): IScreenC.Child =
when (config) {
is Config.ScreenC1 -> IScreenC.Child.ScreenC1(screenC1(componentContext))
is Config.ScreenC2 -> IScreenC.Child.ScreenC2(screenC2(componentContext))
}
private fun screenC1(componentContext: ComponentContext): IScreenC1 =
ScreenC1Component(componentContext) {
router.push(Config.ScreenC2)
}
private fun screenC2(componentContext: ComponentContext): IScreenC2 =
ScreenC2Component(componentContext, onFinished = {
result ->
router.pop()
(router.state.value.activeChild.instance as IScreenC.Child.ScreenC1).component.onResult(result)
})
private sealed class Config : Parcelable {
@Parcelize
object ScreenC1 : Config()
@Parcelize
object ScreenC2 : Config()
}
} Solution 2 - If the router is handling the back buttonclass ScreenB1Component(
private val componentContext: ComponentContext,
private val navigateToB2: () -> Unit
) : IScreenB1, ComponentContext by componentContext {
private val _model = MutableValue(Model(magicNumber = 0))
override val model: Value<Model> = _model
override fun navigateToB2Clicked() {
navigateToB2()
}
override fun onResult(value: Int) {
_model.reduce { it.copy(magicNumber = value) }
}
}
class ScreenB2Component (
private val componentContext: ComponentContext,
private val onFinished: (result: Int) -> Unit
) : IScreenB2, ComponentContext by componentContext {
init {
backPressedDispatcher.register(::onBackPressed)
}
private fun onBackPressed(): Boolean {
onFinished(1234)
// Return false to allow other consumers.
return false
}
}
class ScreenBComponent(
componentContext: ComponentContext
) : IScreenB, ComponentContext by componentContext {
private val router =
router<Config, IScreenB.Child>(
initialConfiguration = Config.ScreenB1,
handleBackButton = true,
childFactory = ::createChild
)
override val routerState: Value<RouterState<*, IScreenB.Child>> = router.state
private fun createChild(config: Config, componentContext: ComponentContext): IScreenB.Child =
when (config) {
is Config.ScreenB1 -> IScreenB.Child.ScreenB1(screenB1(componentContext))
is Config.ScreenB2 -> IScreenB.Child.ScreenB2(screenB2(componentContext))
}
private fun screenB1(componentContext: ComponentContext): IScreenB1 =
ScreenB1Component(componentContext) {
router.push(Config.ScreenB2)
}
private fun screenB2(componentContext: ComponentContext): IScreenB2 =
ScreenB2Component(componentContext, onFinished = {
result ->
// Note if the router handles the back button, don't pop the router here but just use the backstack
((router.state.value.backStack.last() as Child.Created).instance as IScreenB.Child.ScreenB1).component.onResult(result)
})
private sealed class Config : Parcelable {
@Parcelize
object ScreenB1 : Config()
@Parcelize
object ScreenB2 : Config()
} Not quite sure how a coroutines version would look like (or what the benefits would be in this case), but I can imagine it's more verbose similar to the Reaktive version. I think I would favor the first solution since it will make testing a bit easier. |
Beta Was this translation helpful? Give feedback.
The
StateKeeper
is indeed closest toSavedStateHandle
. You are receivingnull
inA1
component because you never set any value with the keyA2_RESULT
. Moreover, it is not possible (yet) to just set a value toStateKeeper
. You can only register value suppliers, and it is saved when the system callsStateKeeperDispatcher.save()
. Also please note, thatStateKeepers
are local to its component, so you can't access values saved in another components. This is same as withSavedStateHandle
. This approach allows you to use same keys in sibling components without clashing. So I think you are doing not as written in the linked Google's documentation page.Regarding the way of handling results recomme…