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>Binderinstance- A View, where we want to render the state of
Feature1, and trigger someWishes 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
Viewis now tightly coupled toFeature1 - The
Viewreally 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
Viewhas 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 Wishes 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 UiEvents
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
Viewdoesn't know anything about aFeature, it only knows how to renderViewModelsand how to triggerUiEvents, and has become a reusable unit in itself. - It can be fed
ViewModelsfrom 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.