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
StateKeeperis indeed closest toSavedStateHandle. You are receivingnullinA1component 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, thatStateKeepersare 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…