Understanding how to dynamically switch between child RIBs
Launch the tutorial5 app, and you can see that there's a new button in the layout: MORE OPTIONS.
If you look at the project structure, you can also find a new RIB in this module: OptionSelector
.
All it does is it renders a screen with text options and a confirm button. We will use that to update the button text in our HelloWorld
rib, and also use it for the actual greeting shown in the Snackbar.
We had this hiearachy so far:
GreetingsContainer
└── HelloWorld
And now we'll add OptionsSelector
as the second child of GreetingsContainer
:
GreetingsContainer
├── HelloWorld
└── OptionsSelector
The idea how they will work together:
- On
HelloWorld
screen, User presses MORE OPTIONS. Since it is beyond the responsibility ofHelloWorld
RIB, it reports it asOutput
GreetingsContainer
catches the output, and switches its routing fromHelloWorld
toOptionsSelector
. Since we display the container on the whole screen, this results in a "new screen" effect.OptionsSelector
offers UI interaction to select something from a radio group. What should happen when a certain options is selected is beyond its responsibilities, so similarly as withHelloWorld
, it reports it asOutput
.GreetingsContainer
catches the output, switches back its routing toHelloWorld
again.- The text of the main button in
HelloWorld
should be updated to reflect the newly selected option. This can be done by via anInput
command toHelloWorld
which allows setting of the text from outside of the RIB.
By now you should be able to:
- Trigger a new event from the UI that reaches the parent as
Output
- Add a new element to
Output
inHelloWorld
calledShowMoreOptions
- Add a new element to
Event
inHelloWorldView
calledMoreOptionsButtonClicked
- In
HelloWorldView
, set a click listener onmoreOptionsButton
that will publishMoreOptionsButtonClicked
- Add the transformation between
Event
andOutput
in theviewEventToOutput
transformer - React to this new output in
GreetingsContainerInteractor
. Leave the actual implementation aTODO()
- Add a new element to
- Add
OptionSelector
RIB as a child ofGreetingsContainer
. This involves:- making
GreetingsContainerComponent
extend child dependency interface - satisfying child dependencies (prepared for you in
GreetingsContainerModule
) - providing
optionsSelectorBuilder
to theGreetingsContainerRouter
- adding a new Configuration to
GreetingsContainerRouter
: "OptionsSelector" - resolving it to an
attach { optionsSelectorBuilder.build() }
action
- making
For help with the above tasks, you can refer to:
- tutorial1 / Further reading section on how to make a Button trigger an
Output
- tutorial2 / Summary section on how to add a child RIB to a parent
- tutorial4 on commnunication with child RIBs, i.e.
Inputs
/Outputs
Right now:
- our new Button can signal the correct
Output
- the container's
Router
can build the other RIB we need
The only thing we need is to connect the dots, so that 1.
actually triggers doing 2.
Business logic triggers routing:
- in
GreetingsContainerInteractor
we consume theOutput
ofHelloWorld
- in the
when
branch for the newOutput
(where we added aTODO()
) we want to tellGreetingsContainerRouter
to switch to the Configuration representingOptionSelector
RIB.
All we need to do is:
class GreetingsContainerInteractor
// ...
internal val helloWorldOutputConsumer: Consumer<HelloWorld.Output> = Consumer {
when (it) {
HelloThere -> output.accept(GreetingsSaid("Someone said hello"))
ShowMoreOptions -> router.push(Configuration.OptionsSelector)
}
}
}
Pressing the MORE OPTIONS button the app should display the new screen:
Try it!
Right now the only way of getting back to HelloWorld
is to press back on the device. We'll address that soon.
Why did the above work?
All Routers
have a routing back stack. By default, this back stack has a single element:
back stack = [(initial configuration)]
This is the one you set in your Router
. In GreetingsContainerRouter
this reads:
class GreetingsContainerRouter(
// ...
initialConfiguration = Configuration.HelloWorld
)
So our default back stack was in fact:
back stack = [*Configuration.HelloWorld]
A configuration can be either active/inactive. In simple terms, it's active if it's on screen.
A simplified rule of the back stack is that only the last configuration is active. We'll mark this with an asterisk (*) from now on.
Router
offers you operations to manipulate this back stack.
fun push(configuration: Content)
fun popBackStack(): Boolean
There are other operations too, but we'll discuss them later in other tutorials. What's important is that push
adds a new element to the end of the back stack, while popBackStack
removes the last one from the end.
So when we did router.push(Configuration.OptionsSelector)
, this happened:
back stack 0 = [*Configuration.HelloWorld]
// push
back stack 1 = [Configuration.HelloWorld, *Configuration.OptionsSelector]
And because we just said that the last element in the back stack is active (on screen), this means that the view of HelloWorld
gets detached, and OptionsSelector
gets created and attached.
The reverse is happening when we pressed back.
By default, back pressing is propagated to the deepest active levels of the RIB tree by default, where each RIB has a chance to react to it:
Interactor
has a chance to overridehandleBackPress()
to do something based on business logicRouter
will be asked if it has back stack to pop. If yes, pop is triggered and nothing else is done.- If
Router
had only one more configuration left in its back stack, there's nothing more to remove. The whole thing starts bubbling up the RIB tree to higher levels until one of the levels can handle it (points 1 and 2). If it is handled, the propagation stops. - If the whole RIB tree didn't handle the back press, then the last fallback is the Android environment (in practice this probably means that the hosting
Activity
finishes).
In our case, when we were on the OptionsSelector
screen, GreetingsContainerRouter
had 2 elements in its back stack, so it could automatically handle the back press event by popping the latest:
back stack 0 = [*Configuration.HelloWorld]
// push
back stack 1 = [Configuration.HelloWorld, *Configuration.OptionsSelector]
// back press
back stack 2 = [*Configuration.HelloWorld]
And again, because last element in the back stack is on screen, this means that OptionsSelector
gets detached, and HelloWorld
gets attached back to the screen again.
Of course there's no point of opening the second screen if we cannot interact with it and our only option is to press back.
So let's make it a bit more useful:
- Add a new element to
Output
inOptionsSelector
:data class OptionSelected(val text: Text) : Output()
- Add a new element to
Event
inOptionsSelectorView
:data class ConfirmButtonClicked(val selectionIndex: Int) : Event()
- In
OptionsSelectorViewImpl
, add a click listener onconfirmButton
that will trigger this event. - Go to
OptionSelectorInteractor
. Add the transformation betweenEvent
andOutput
in theviewEventToOutput
transformer. - Go to
GreetingsContainerInteractor
, and add a branch to thewhen
expression inmoreOptionsOutputConsumer
What we want to do is:
- Take the
Text
that's coming in theOutput
- Feed it to
HelloWorld
using an Input ofUpdateButtonText
- Actually go back one screen = manually popping the back stack
This is how it might look:
internal val optionsSelectorOutputConsumer: Consumer<OptionSelector.Output> = Consumer {
when (it) {
is Output.OptionSelected -> {
router.popBackStack()
helloWorldInputSource.accept(
UpdateButtonText(it.text)
)
}
}
}
At this point we should be able to go to options selection screen, chose an item from the radio group, and pressing the confirm button we should land back at the Hello world! screen with the label of the hello button reflecting our choice.
Press the button!
We just created more complex functionality by a composition of individually simple pieces!
When we created our hierarchy like this, we kept the two children decoupled from each other:
GreetingsContainer
├── HelloWorld
└── OptionsSelector
Even though they work together as a system, HelloWorld
and OptionsSelector
has no dependency on each other at all.
This is actually beneficial, because:
OptionsSelector
is a generic screen (it renders whatever text options we build it with)- From the perspective of
HelloWorld
it really shouldn't care where it gets its other greetings
Keeping them decoupled means:
OptionsSelector
can be reused freely elsewhere to render the same screen with other optionsHelloWorld
can be reused with different implementation details how to provide more options to it
The combined functionality we just implemented emerges out of the composition of these pieces inside GreetingsContainer
. Each level handles only its immediate concerns:
HelloWorld
implements hello functionality and can ask for more optionsOptionsSelector
renders options and signals selectionGreetingsContainer
connects the dots and contains only coordination logic. All other things are delegated to child screens as implementation details.
Congratulations! You can advance to the next one.
Dynamic routing
- Make the parent RIB be able to build a child RIB (as seen in tutorial2):
- Add configuration & routing action in Router
- Provide child
Builder
via DI
- React to some event (usually to child
Output
as seen in tutorial4, but can be anything else) inInteractor
of parent RIB by pushing new configuration to itsRouter
- Use back press or
popBackStack()
programmatically to go back
Composing functionality
- Instead of one messy RIB, map complex functionality to a composition of simple, single responsibility RIBs
- When composing, keep parent levels simple. They should only coordinate between child RIBs by
Inputs
/Outputs
, and delegate actual functionality to children as implementation details. - Sibling RIBs on the same level should not depend on each other, so that they can be easily reused elsewhere.