Binding Features to the UI (and other reactive components)¶
I have my Feature, now what?¶
Let's take a step by step approach how to connect our Features to the UI.
Let's suppose we have:
Feature1<Wish, State>
Binder
instance- A View, where we want to render the state of
Feature1
, and trigger someWish
es on them.
Step 1: Direct binding¶
class View : Consumer<Feature1.State> {
val binder: Binder = TODO()
val feature: Feature1 = TODO()
val button: Button = TODO()
val counter: TextView = TODO()
val image: ImageView = TODO()
val progress: ProgressBar = TODO()
fun onCreate() {
setupBindings()
setupViews()
}
fun setupBindings() {
binder.bind(feature to this)
}
private fun setupViews() {
button.setOnClickListener {
// directly talking to feature
feature.accept(Feature1.Wish.Foo)
}
}
override fun accept(state: Feature1.State) {
counter.text = (state.counter1 + state.counter2) % 2 // "complex logic"
image.url = state.imageUrls.first { it.contains("imgur") } // "complex logic"
if (state.isLoading) progress.visible() else progress.hide()
}
}
In this example, View
accepts State
directly, and talks to Feature1
directly. This is wrong for multiple reasons:
- The
View
is now tightly coupled toFeature1
- The
View
really shouldn't care where it gets the data it wants to display on the screen from. It shouldn't render data models, but rather view models that doesn't require local logic to transform (see the comments about "complex logic"). - The
View
has the additional responsibility of managing bindings
Let's fix these one by one.
Step 2: Extract bindings¶
class Bindings @Inject constructor(
private val feature: Feature1
) {
val binder: Binder = TODO()
fun setup(view: View) {
binder.bind(feature to view)
}
}
Now the extra concern is lifted from the View
, and it only cares about its input (State
) and output (triggering Wish
). But let's not stop here.
Step 3: Don't render the State, render a ViewModel¶
Define your ViewModel
however you see fit. It should contain processed, "dumb", simple to display data only, and only what is actually required for your View
:
data class ViewModel(
val counter: Int,
val imageUrl: String,
val isLoading: Boolean
)
Convert the State
to a ViewModel
with a ViewModelTransformer
:
object ViewModelTransformer : (Feature1.State) -> ViewModel {
override fun invoke(state: Feature1.State): ViewModel =
ViewModel(
// 1. If the State stores data in another / more complex format,
// mapping to simple values should be done here, and not in the View
// 2. Also the State might contain a lot more stuff,
// here we only pass on those actually needed for the View
counter = (state.counter1 + state.counter2) % 2,
imageUrl = state.imageUrls.first { it.contains("imgur") },
isLoading = state.isLoading
)
}
Modify View
to consume ViewModel
, it becomes much simpler without data model parsing logic:
class View : Consumer<ViewModel> {
// remainder omitted
override fun accept(vm: ViewModel) {
counter.text = vm.counter
image.url = vm.imageUrl
if (state.isLoading) progress.visible() else progress.hide()
}
}
Modify the Bindings
so that it uses the ViewModelTransformer
class Bindings @Inject constructor(
private val feature: Feature1
) {
val binder: Binder = TODO()
fun setup(view: View) {
binder.bind(feature to view using ViewModelTransformer)
}
}
Now the View
doesn't care where it gets its ViewModel
from, and can be reused to work with other data sources as well.
Step 3: Don't emit Wish, emit a UI Event¶
There's one last thing that's still coupling our View
to our Feature1
— triggering its Wish
es directly. Now that the View
doesn't know where the ViewModel
comes from, why should it talk to Feature1
directly? All it really cares about it is to provide some output. The fact that this can trigger state changes and a new ViewModel
to render is secondary from its perspective.
Let's define our UI events as:
sealed class UiEvent {
object ButtonClicked : UiEvent()
object ImageClicked : UiEvent()
}
Let's remove the Feature1
reference from our View
, and make it a source of UiEvent
s
class View(
private val uiEvents: PublishRelay<UiEvent> = PublishRelay.create()
) : Consumer<ViewModel>, ObservableSource<UiEvent> by uiEvents {
// remainder omitted
private fun setupViews() {
button.setOnClickListener { uiEvents.accept(UiEvent.ButtonClicked) }
image.setOnClickListener { uiEvents.accept(UiEvent.ImageClicked) }
}
}
Now we can connect our View
to our Feature1
using a transformer, much like how we did with State
-> ViewModel
, only this time it's in the other direction:
object UiEventTransformer : (UiEvent) -> Feature1.Wish? {
override fun invoke(event: UiEvent): Feature1.Wish? = when (event) {
is ButtonClicked -> Feature1.Wish.SetActiveButton(event.idx)
is PlusClicked -> Feature1.Wish.IncreaseCounter
}
}
class Bindings @Inject constructor(
private val feature: Feature1
) {
val binder: Binder = TODO()
fun setup(view: View) {
binder.bind(view to feature using UiEventTransformer)
binder.bind(feature to view using ViewModelTransformer)
}
}
Step 4: Profit¶
Let's consider the benefits so far:
- We completely decoupled our UI and our business logic.
- Our
View
doesn't know anything about aFeature
, it only knows how to renderViewModels
and how to triggerUiEvents
, and has become a reusable unit in itself. - It can be fed
ViewModels
from any other source. - Bindings, along with their lifecycle, are a separate concern.
Additionally, now that we trigger UiEvents
in the View
, we can bind multiple other components to it in a completely decoupled way!
Let's add an analytics tracker:
class AnalyticsTracker() : Consumer<UiEvent> {
override fun accept(uiEvent: UiEvent) {
when (uiEvent) {
is ButtonClicked -> TODO()
is PlusClicked -> TODO()
}
}
}
And now we can connect it with just one additional line in our Bindings
:
class Bindings @Inject constructor(
private val feature: Feature1
private val analyticsTracker: AnalyticsTracker
) {
val binder: Binder = TODO()
fun setup(view: View) {
binder.bind(view to analyticsTracker)
binder.bind(view to feature using UiEventTransformer)
binder.bind(feature to view using ViewModelTransformer)
}
}
All this without modifying anything in our View
!
Once you add multiple reactive components, the Bindings
class becomes your high level overview of the whole graph of who talks to whom, in a really descriptive way.