This reworks how we handle event sinks by embedding them within our state types instead of making them a top-level component of how Presenter
and Ui
work. Similar to what we were doing earlier on, but without the testability friction.
The goal in this is to simplify our API ergonomics and also avoid some friction we've been running into with backing them with Flow
.
@adamp put it well in a private chat
came out of some discussions from last night and this morning; we had talked before about passing forward the event emitter to a composable content lambda of the presenter, but the "continuation" [reference to how Coroutines passes a Continuation
object around] made up of the caller after the return serves the same purpose with a tidier structure
Which makes the whole thing analogous to passing some state and some callbacks to a regular composable, with all of the scoping behavior and expectations implied by that
Now a presenter is just this
class Presenter<UiState : CircuitUiState> {
@Composable fun present(): UiState
}
And a simple counter example would be like this
data class CounterState(val count: Int, val eventSink: (CounterEvent) -> Unit) : CircuitUiState
sealed interface CounterEvent : CircuitUiEvent {
object Increment : CounterEvent
object Decrement : CounterEvent
}
class CounterPresenter<State> {
@Composable
override fun present(): State {
var count by remember { mutableStateOf(0) }
return CounterState(count) { event ->
when (event) {
is Increment -> count++
is Decrement -> count--
}
}
}
}
@Composable fun Counter(state: State) {
Column {
Text("Count: ${state.count}")
Button(onClick = { state.eventSink(Increment) } { Text("Increment") }
Button(onClick = { state.eventSink(Decrement) } { Text("Decrement") }
}
}
Pros
- Simpler in general for multiple reasons detailed in subsequent bullets, but just look at at the line diff on this PR alone
- Only one type variable on
Presenter
and Ui
types now.
- Significantly simpler ergonomics with nested events. No ceremony with remembers and flow operators
- Simpler ergonomics with event-less
Ui
s. Simply omit events, no need for Nothing
.
- Testing is simpler, no manual event flow needed. Instead you tick your presenter along with the returned state's event sink.
- Forces you to write more realistic tests too. For example – a "no animals" state test immediately stood out in tinkering here because you can't click an animal in that case and have no sink to emit to, so the test issue was obvious here and needed to be addressed.
@Test
fun `present - navigate to pet details screen`() = runTest {
val repository = TestRepository(listOf(animal))
val presenter = PetListPresenter(navigator, PetListScreen(), repository)
presenter.test {
assertThat(PetListScreen.State.Loading).isEqualTo(awaitItem())
val successState = awaitItem()
check(successState is PetListScreen.State.Success)
assertThat(successState.animals).isEqualTo(listOf(animal).map { it.toPetListAnimal() })
val clickAnimal = PetListScreen.Event.ClickAnimal(123L, "key")
successState.eventSink(clickAnimal)
assertThat(navigator.awaitNextScreen())
.isEqualTo(PetDetailScreen(clickAnimal.petId, clickAnimal.photoUrlMemoryCacheKey))
}
}
- Different state types can have different event handling (e.g.
Click
may not make sense for Loading
states)
- No ceremony around setting up a channel and multicasting event streams
- Currently, while functions are treated as implicitly
Stable
by the compose compiler but not skippable when they're non-composable Unit-returning lambdas with equal-but-unstable captures. This may change though, and would be another free benefit for this case.
- No risk of dropping events (unlike Flow)
- Simpler overhead due to no coroutines in basic case
Neutral (could be pro or con)
- States could contain event sink functions or expose an interface that
Ui
s could call functions on
Cons
- State needs to be unpacked in the presenter function and then bundled up when returned.
- Could be a good thing though with better granularity. We've already seen cases where we aggregate multiple states into the final one
- Event sinks being part of state objects may seem surprising at first
- They do make sense when considering different states have different contexts and events that may emit within them.
- Simple states (such as Loading) or multiple substates may feel slightly more pedantic if they must expose event sinks or share the same event logic.
- Pattern so far – write shared event logic once and reuse it, such as a function call or local remembered function var.