To cover how to implement proper Input / Output communication with child RIBs.
Every RIB can potentially expose a set of Inputs and Outputs on its main interface.
This is the public API of the RIB, and every RIB guarantees that as long as the proper dependencies are supplied (i.e. a consumer of its Output that it can talk to / a source of its Input that it can observe), it will automatically wire the required setup internally.
Which is an amazing thing when you think about it:
- When using a RIB, it's not necessary to look inside and understand its internals to make it work, it's enough to have a look at its public API
- It's also not necessary to create any additional wiring or setup to make it work: if you can build it, it works!
There's one thing we left out so far: in tutorial2 we removed the functionality of the Say hello! button, and we said we'd put it back later.
Now's the time!
The classes in this tutorial have all the removed pieces put back in:
HelloWorldinterface (Outputand dependency for it)HelloWorldModule- usesoutputwhen constructing InteractorHelloWorldInteractor- constructor parameter, and usingoutputinonViewCreatedViewEventToOutputin thehello_world.mapperpackage
This means that as far as HelloWorld RIB is concerned, when its button is pressed, it will correctly trigger Output.HelloThere on its output channel.
Now of course we need to provide the dependency for consuming this Output from GreetingsContainer.
This will be similar to what we did in the previous tutorial - we will satisfy it in the parent, directly.
More specifically, as Outputs and Inputs are forms of communication with a RIB, they should be handled as part of the business logic. Which means, we will want to implement it in the parent Interactor.
Let's open GreetingsContainerInteractor and implement reacting to its child's Output. What to do with its Output actually? Well, GreetingsContainer has its own Output that it communicates to the outside world, so right now we will make it trigger just that.
// mind the correct imports:
import com.badoo.ribs.tutorials.tutorial5.rib.hello_world.HelloWorld
import android.os.Bundle
class GreetingsContainerInteractor(
savedInstanceState: Bundle?,
router: Router<Configuration, Nothing, Configuration, Nothing, Nothing>,
output: Consumer<GreetingsContainer.Output>
) : Interactor<Configuration, Configuration, Nothing, Nothing>(
savedInstanceState = savedInstanceState,
router = router
) {
internal val helloWorldOutputConsumer: Consumer<HelloWorld.Output> = Consumer {
when (it) {
HelloThere -> output.accept(GreetingsSaid("Someone said hello!"))
}
}
}Now let's tell Dagger to use this!
Scroll to the bottom of GreetingsContainerModule and replace the TODO() block you find there to the object we created:
@GreetingsContainerScope
@Provides
@JvmStatic
internal fun helloWorldOutputConsumer(
interactor: GreetingsContainerInteractor
) : Consumer<HelloWorld.Output> =
interactor.helloWorldOutputConsumer"If you can give me GreetingsContainerInteractor, I know how to give you a Consumer<HelloWorld.Output>".
And since GreetingsContainerInteractor was marked with @GreetingsContainerScope in the same DI configuration (check the method named interactor), it's treated as a singleton in the scope, making Dagger reuse the same exact instance of it when constructing this Consumer, instead of creating a new one.
Now we've established a chain:
HelloWorldView =(Event)⇒ ViewEventToOutput =(HelloWorld.Output)⇒ GreetingsContainerInteractor =(GreetingsContainer.Output)⇒ RootActivity
The tutorial4 app should build and run at this point, and display the Snackbar when the button is pressed.
Let's say we want to update the text on the button from outside of the RIB.
To expose this functionality, let's add an Input to HelloWorld:
interface HelloWorld : Rib {
interface Dependency {
fun helloWorldInput(): ObservableSource<Input> // add this
fun helloWorldOutput(): Consumer<Output>
// remainder omitted
}
// add this
sealed class Input {
data class UpdateButtonText(val text: Text): Input()
}
sealed class Output {
object HelloThere : Output()
}
// remainder omitted
} Note that we added a new dependency, too, on ObservableSource<Input>.
Reminder – as we've established in tutorial1:
- if a RIB has
Input, then it also has a dependency ofObservableSource<Input>- if a RIB has
Output, then it also has a dependency ofConsumer<Output>This is so that we can implement the guaranteed auto-wiring functionality of the RIB, described in the Outline section at the beginning of this tutorial
Let's use this input - add it to the constructor of our Interactor inside HelloWorldModule:
@HelloWorldScope
@Provides
@JvmStatic
internal fun interactor(
savedInstanceState: Bundle?,
user: User,
config: HelloWorld.Config,
input: ObservableSource<HelloWorld.Input>, // add this
output: Consumer<HelloWorld.Output>
): HelloWorldInteractor =
HelloWorldInteractor(
savedInstanceState = savedInstanceState,
user = user,
config = config,
input = input, // add this
output = output
)And:
class HelloWorldInteractor(
savedInstanceState: Bundle?,
private val user: User,
private val config: HelloWorld.Config,
private val input: ObservableSource<HelloWorld.Input>, // add this
private val output: Consumer<HelloWorld.Output>
)Now let's implement the functionality on the other end, before connecting the dots.
If we want to change the label of the button, we need to go to the view:
interface HelloWorldView : RibView,
ObservableSource<Event>,
Consumer<ViewModel> {
sealed class Event {
object ButtonClicked : Event()
}
data class ViewModel(
val titleText: Text,
val welcomeText: Text,
val buttonText: Text // add this
)
}And in HelloWorldViewImpl:
override fun accept(vm: ViewModel) {
title.text = vm.titleText.resolve(androidView.context)
welcome.text = vm.welcomeText.resolve(androidView.context)
button.text = vm.buttonText.resolve(androidView.context) // add this
}Now we need to update providing a ViewModel from the Interactor.
We can easily modify the initial one by adding the last line:
view.accept(
HelloWorldView.ViewModel(
titleText = Text.Resource(R.string.hello_world_title, user.name()),
welcomeText = config.welcomeMessage,
buttonText = Text.Resource(R.string.hello_world_button_text)
)
)But this will only create one, and never update it - and we want to do just that, whenever an Input.UpdateButtonText arrives.
We can use the Binder for that, similarly to how we already use it just a couple lines below the ViewModel creation:
viewLifecycle.startStop {
bind(view to output using ViewEventToOutput)
}To outline what we want to do, add a new line here:
viewLifecycle.startStop {
bind(view to output using ViewEventToOutput)
bind(input to view using InputToViewModel)
}Create InputToViewModel in the mapper package:
package com.badoo.ribs.tutorials.tutorial4.rib.hello_world.mapper
import com.badoo.ribs.android.text.Text
import com.badoo.ribs.tutorials.tutorial4.R
import com.badoo.ribs.tutorials.tutorial4.rib.hello_world.HelloWorld
import com.badoo.ribs.tutorials.tutorial4.rib.hello_world.HelloWorld.Input.UpdateButtonText
import com.badoo.ribs.tutorials.tutorial4.rib.hello_world.HelloWorldView.ViewModel
import com.badoo.ribs.tutorials.tutorial4.util.User
class InputToViewModel(
private val user: User,
private val config: HelloWorld.Config
) : (HelloWorld.Input) -> ViewModel? {
override fun invoke(input: HelloWorld.Input): ViewModel? =
when (input) {
is UpdateButtonText -> ViewModel(
titleText = Text.Resource(R.string.hello_world_title, user.name()),
welcomeText = config.welcomeMessage,
buttonText = input.text // using the incoming data
)
}
}Two things worth mentioning here:
-
Notice how the return type is nullable:
ViewModel?– meaning we don't have to create one if we don't want to. A legitimate use-case is if in the future we decide to add more types ofInput, and not all of them actually do something with the view – in those branches in thewhenexpression we can just returnnull, andBinderwill not propagate it to the view. -
Notice that we actually needed to pass
userandconfighere, so it can no longer be just a Kotlin object. Modify ourInteractorto create it:
// create the transformer
private val inputToViewModel = InputToViewModel(user, config)
override fun onViewCreated(view: HelloWorldView, viewLifecycle: Lifecycle) {
super.onViewCreated(view, viewLifecycle)
setInitialViewModel(view)
viewLifecycle.startStop {
bind(view to output using ViewEventToOutput)
bind(input to view using inputToViewModel) // use it here
}
}
// extracted this to a method too
private fun setInitialViewModel(view: HelloWorldView) {
view.accept(
HelloWorldView.ViewModel(
titleText = Text.Resource(R.string.hello_world_title, user.name()),
welcomeText = config.welcomeMessage,
buttonText = Text.Resource(R.string.hello_world_button_text)
)
)
}Alternatively, we could create the transformer in our DI module and pass it in the constructor (then we could spare passing in
userandconfig). Since we don't need it outside of theInteractor, and since it's just a simple mapper (it's not like we'd want to mock it), it's also ok to keep it here. Up to you I guess.
Right now, we only implemented the functionality to react to exposed Input type on the HelloWorld side of things.
Now we also need to satisfy the dependency of ObservableSource<Input> in the parent, which we will do similarly to how we did with Output before:
class GreetingsContainerInteractor(
savedInstanceState: Bundle?,
router: Router<Configuration, Nothing, Configuration, Nothing, Nothing>,
output: Consumer<GreetingsContainer.Output>
) : Interactor<Configuration, Configuration, Nothing, Nothing>(
savedInstanceState = savedInstanceState,
router = router
) {
// Add this:
internal val helloWorldInputSource: Relay<HelloWorld.Input> = BehaviorRelay.create()
internal val helloWorldOutputConsumer: Consumer<HelloWorld.Output> = Consumer {
when (it) {
HelloThere -> output.accept(GreetingsSaid("Someone said hello"))
}
}
// Add this to send an Input immediately when this RIB attaches
override fun onAttach(ribLifecycle: Lifecycle, savedInstanceState: Bundle?) {
super.onAttach(ribLifecycle, savedInstanceState)
helloWorldInputSource.accept(
HelloWorld.Input.UpdateButtonText(
Text.Plain("Woo hoo!")
)
)
}
}Right now we will only provide an
Inputstatically and once. We will make it more useful in the next tutorial.
And add it to the bottom of GreetingsContainerModule so that Dagger understands to grab this when providing HelloWorld.Dependency:
@GreetingsContainerScope
@Provides
@JvmStatic
internal fun helloWorldInputSource(
interactor: GreetingsContainerInteractor
) : ObservableSource<HelloWorld.Input> =
interactor.helloWorldInputSourceNow we've established a chain:
GreetingsContainerInteractor =(HelloWorld.Input)⇒Relay⇒InputToViewModel =(ViewModel)⇒ HelloWorldView
The app should now compile. Test it!
Congratulations! You can advance to the next one.
Inputs and Outputs
-
InputsandOutputsare forms of communication with a RIB:Inputsexpose functionality that can be triggered from the outsideOutputssignal some events where deciding what to do lies beyond the responsibility of the RIB in question
-
Paired dependencies:
Inputsalways come with a dependency ofObservableSource<Input>.Outputsalways come with a dependency ofConsumer<Output>.- This is to ensure auto-wiring of the RIB, so that as long as you can build it, it works automatically.
-
When satisfying
OutputandInputdependencies, we always want to do that directly in the parent:- By satisfying these dependencies, we create a connection between the place where we satisfy them and the RIB in question.
- Having a certain RIB as a child is an implementation detail of the parent, that should be kept hidden to maintain flexibility.
- If we were to bubble up the dependency to a higher level, we would expose this implementation detail. We would create a connection between the outside world and the implementation detail, making the immediate parent lose its ability to easily change it. This should be avoided at all costs.
- If the parent cannot handle an
Outputmessage directly, it can transform it to its ownOutputtype, keeping the implementation detail hidden.
