Presenter¶
The core Presenter interface is this:
interface Presenter<UiState : CircuitUiState> {
@ComposableTarget("presenter")
@Composable
fun present(): UiState
}
Presenters are solely intended to be business logic for your UI and a translation layer in front of your data layers. They are generally Dagger-injected types as the data layers they interpret are usually coming from the DI graph. In simple cases, they can be typed as a simple @Composable
presenter function allowing Circuit code gen to generate the corresponding interface and factory for you.
A very simple presenter can look like this:
class FavoritesPresenter(...) : Presenter<State> {
@Composable override fun present(): State {
var favorites by remember { mutableStateOf(<initial>) }
return State(favorites) { event -> ... }
}
}
In this example, the present()
function simply computes a state
and returns it. If it has UI events to handle, an eventSink: (Event) -> Unit
property should be exposed in the State
type it returns.
With DI, the above example becomes something more like this:
class FavoritesPresenter @AssistedInject constructor(
@Assisted private val screen: FavoritesScreen,
@Assisted private val navigator: Navigator,
private val favoritesRepository: FavoritesRepository
) : Presenter<State> {
@Composable override fun present(): State {
// ...
}
@AssistedFactory
fun interface Factory {
fun create(screen: FavoritesScreen, navigator: Navigator, context: CircuitContext): FavoritesPresenter
}
}
Assisted injection allows passing on the screen
and navigator
from the relevant Presenter.Factory
to this presenter for further reference.
When dealing with nested presenters, a presenter could bypass implementing a class entirely by simply being written as a function that other presenters can use.
// From cashapp/molecule's README examples
@Composable
fun ProfilePresenter(
userFlow: Flow<User>,
balanceFlow: Flow<Long>,
): ProfileModel {
val user by userFlow.collectAsState(null)
val balance by balanceFlow.collectAsState(0L)
return if (user == null) {
Loading
} else {
Data(user.name, balance)
}
}
Presenters can present other presenters by injecting their assisted factories/providers, but note that this makes them a composite presenter that is now assuming responsibility for managing state of multiple nested presenters.
No Compose UI¶
Presenter logic should not emit any Compose UI. They are purely for presentation business logic. To help enforce this, Presenter.present
is annotated with @ComposableTarget("presenter")
. This helps prevent use of Compose UI in the presentation logic as the compiler will emit a warning if you do.
Tip
This warning does not appear in the IDE, so it’s recommended to use allWarningsAsErrors
in your build configuration to fail the build on this event.
// In build.gradle.kts
kotlin.compilerOptions.allWarningsAsErrors.set(true)
Retention¶
There are three types of composable retention functions used in Circuit.
remember
– from Compose, remembers a value across recompositions. Can be any type.rememberRetained
– custom, remembers a value across recompositions, the back stack, and configuration changes. Can be any type, but should not retain leak-able things likeNavigator
instances orContext
instances. Backed by a hiddenViewModel
on Android.rememberSaveable
– from Compose, remembers a value across recompositions, the back stack, configuration changes, and process death. Must be a primitive,Parcelable
(on Android), or implement a customSaver
. This should not retain leakable things likeNavigator
instances orContext
instances and is backed by the framework saved instance state system.
Developers should use the right tool accordingly depending on their use case. Consider these three examples.
The first one will preserve the count
value across recompositions, but not the back stack, configuration changes, or process death.
@Composable
fun CounterPresenter(): CounterState {
var count by remember { mutableStateOf(0) }
return CounterState(count) { event ->
when (event) {
is CounterEvent.Increment -> count++
is CounterEvent.Decrement -> count--
}
}
}
The second one will preserve the state across recompositions, the back stack, and configuration changes, but not process death.
@Composable
fun CounterPresenter(): CounterState {
var count by rememberRetained { mutableStateOf(0) }
return CounterState(count) { event ->
when (event) {
is CounterEvent.Increment -> count++
is CounterEvent.Decrement -> count--
}
}
}
The third case will preserve the count
state across recompositions, the back stack, configuration changes, and process death.
@Composable
fun CounterPresenter(): CounterState {
var count by rememberSaveable { mutableStateOf(0) }
return CounterState(count) { event ->
when (event) {
is CounterEvent.Increment -> count++
is CounterEvent.Decrement -> count--
}
}
}
remember |
rememberRetained |
rememberSaveable |
|
---|---|---|---|
Recompositions | ✅ | ✅ | ✅ |
Back stack | ❌ | ✅* | ✅* |
Configuration changes (Android) | ❌ | ✅ | ✅ |
Process death | ❌ | ❌ | ✅ |
Can be non-Saveable types | ✅ | ✅ | ❌ |
*If using NavigableCircuitContent
’s default configuration.