In this case study, we will build a note-taking app that lets the user add, edit and delete notes. It uses Compose for both the view and presentation layers!
Note: This is a follow up to Part 1: Simplifying State Management with Compose and assumes the reader is already familiar with Jetpack Compose.
The Template
I find it useful to start with the model that represents the state of the screen we’re building. It will have a list of notes, with each note containing properties for the text and checkbox:
data class NotesUiModel(val notes: List<Note>) : UiModel {
data class Note(val text: String, val isChecked: Boolean) : UiModel
}
It’ll be the job of the presenter to produce this model. Initially, let’s implement a stub to return an empty list of notes (we’ll ignore parameters for now):
class NotesListPresenter : Presenter<NotesUiModel, Unit> {
@Composable
override fun present(params: Unit): NotesUiModel {
return NotesUiModel(notes = emptyList())
}
}
Then we can build our view to render the model that’s returned by the presenter:
@Composable
fun NotesScreen() {
val presenter: NotesListPresenter = koinInject() // we use koin for DI
val uiModel = presenter.present(Unit)
Column {
TopAppBar(title = { Text("Notes") })
Notes(uiModel)
}
}
}
The View
The views themselves are pretty self-explanatory if you’re already familiar with building UIs in Compose. For the notes, we’ll take in a NotesUiModel
argument and create a LazyColumn
with the notes
property. A FAB button is used for adding new notes, although we’ll skip the triggering of events and return to this part in a little bit:
@Composable
private fun Notes(uiModel: NotesUiModel) {
Box {
LazyColumn {
items(uiModel.notes) { note ->
Note(note)
}
}
FloatingActionButton(onClick = { /* TODO */ }) {
Icon(imageVector = Icons.Rounded.Add)
}
}
}
Then we can render each note with a checkbox, text field and delete button:
@Composable
private fun Note(uiModel: Note) {
Row {
Checkbox(
checked = uiModel.isChecked,
onCheckedChange = { /* TODO */ }
)
TextField(
value = uiModel.text,
textStyle = TextStyle(
textDecoration = if (uiModel.isChecked) {
TextDecoration.LineThrough
} else {
TextDecoration.None
}
),
placeholder = { Text("Add note") },
onValueChange = { /* TODO */ }
)
IconButton(onClick = { /* TODO */ }) {
Icon(imageVector = Icons.Default.Delete)
}
}
}
The state of the Note
is used to determine whether the Checkbox
is checked or not and also for the current value of the TextField
. Again, we’ll implement the events later on.
The Presenter
Returning to the presenter, we’ll need to obtain the data for the notes. Using Clean Architecture, the presenter will interact with the data layer via use cases. We can inject an ObserveNotesUseCase
into NotesListPresenter
and then invoke it with the aid of produceState
:
class NotesListPresenter(
val observeNotesUseCase: ObserveNotesUseCase
) : Presenter<NotesUiModel, Unit> {
@Composable
override fun present(params: Unit): NotesUiModel {
val notes: List<NoteDataModel> by produceState(initialValue = emptyList()) {
observeNotesUseCase().collect {
value = it
}
}
// ...
}
}
ObserveNotesUseCase
returns a Flow<List<NoteDataModel>>
. In order to collect the flow, we need a CoroutineScope
, which produceState
provides us. In addition, the code block passed to produceState
will only execute once. This prevents us from recollecting the flow every time present()
recomposes.
To help cut down the noise of the produceState
call, we can encapsulate it with an extension function:
@Composable
fun <T> FlowUseCase<T>.launchUseCase(initial: T): State<T> {
return produceState(initialValue = initial) {
invoke().collect {
value = it
}
}
}
Which let’s us write the much briefer:
val notes by observeNotesUseCase.launchUseCase(initial = emptyList())
Now that we have the data, we can use it to derive the model:
return NotesUiModel(
notes = notes.map { note ->
var text by remember(note.id) { mutableStateOf(note.text) }
var isChecked by remember(note.id) { mutableStateOf(note.isFinished) }
Note(text, isChecked)
}
)
We map
over each note in the list, transforming the NoteDataModel
into a NotesUiModel.Note
. We create two MutableState
variables for text
and isChecked
, each initialized to their respective values from the data.
Note: If we omitted note.id
as a key to remember
, Compose would associate each state with its position in the list, rather than a specific NoteDataModel
. Deleting notes in the list, which we’ll implement shortly, would cause the states to be out of sync with the list.
The Events
We still need to add events, that way the view can communicate to the presenter. We want to support the following operations:
- Adding a new note
- Updating the checkbox of a note
- Updating the text of a note
- Deleting a note
The first operation, adding a new note, will be fired off as an event by the FAB button. We can simply add an EventHandler
to NotesUiModel
to support this:
data class NotesUiModel(
val notes: List<Note>,
val events: EventHandler<Event>
) : UiModel {
sealed interface Event : UiEvent {
object OnAdd : Event
}
// ...
}
In the view, the FAB button can now invoke the EventHandler
of the model and pass it Event.OnAdd
:
FloatingActionButton(onClick = { uiModel.events.handle(Event.OnAdd) }) {
Icon(imageVector = Icons.Rounded.Add)
}
We need to handle this event on the presenter side, which we’ll accomplish with the aid of CreateNoteUseCase
:
class NotesListPresenter(
// ...
val createNoteUseCase: CreateNoteUseCase
) : Presenter<NotesUiModel, Params> {
@Composable
override fun present(params: Unit): NotesUiModel {
// ...
}
}
This particular use case runs as a suspend
function and must be invoked from a CoroutineScope
, which we can access inside of a Composable function with rememberCoroutineScope()
:
@Composable
override fun present(params: Unit): NotesUiModel {
val scope = rememberCorotuineScope()
// ...
}
Then we can handle the event in the model that gets returned:
return NotesUiModel(
notes = notes.map { note ->
// ...
},
events = EventHandler { event ->
when (event) {
is Event.OnAdd -> scope.launch {
createNoteUseCase()
}
}
}
)
When CreateNoteUseCase
is invoked, it updates a data cache that will trigger a new emission on the flow returned by ObserveNotesUseCase
. This emission will cause the present()
function to recompose and return an updated model that contains the newly created note.
The other three operations are tied to a specific note in the list, therefore we want to add a separate EventHandler
to the Note
model:
data class Note(
val text: String,
val isChecked: Boolean,
val events: EventHandler<Event>
) : UiModel {
sealed interface Event : UiEvent {
object OnCheck : Event
data class OnUpdateText(val text: String) : Event
object OnDelete : Event
}
}
As before, we’ll inject the necessary use cases:
class NotesListPresenter(
// ...
val updateNoteUseCase: UpdateNoteUseCase,
val deleteNoteUseCase: DeleteNoteUseCase
) : Presenter<NotesUiModel, Unit> {
@Composable
override fun present(params: Unit): NotesUiModel {
// ...
}
}
Then handle the events when creating each Note
:
return NotesUiModel(
notes = notes.map { note ->
var text by remember(note.id) { mutableStateOf(note.text) }
var isChecked by remember(note.id) { mutableStateOf(note.isFinished) }
Note(
text = text,
isChecked = isChecked,
events = EventHandler { event ->
when (event) {
Note.Event.OnCheck -> {
isChecked = isChecked.not()
scope.launch {
updateNoteUseCase(NoteDataModel(note.id, text, isChecked))
}
}
is Note.Event.OnUpdateText -> {
text = event.text
scope.launch {
updateNoteUseCase(NoteDataModel(note.id, text, isChecked))
}
}
Note.Event.OnDelete -> scope.launch {
deleteNoteUseCase(note.id)
}
}
}
)
},
// ...
)
In the case of Note.Event.OnCheck
and Note.Event.OnUpdateText
, we are updating the local state before invoking UpdateNoteUseCase
. This ensures that the UI stays responsive when the user rapidly enters text or clicks on a checkbox. If UpdateNoteUseCase
instead triggered a new emission via ObserveNotesUseCase
, the user might see buggy behavior since the flow may not keep up with the speed of the user’s inputs. On the other hand, deleting a note is a one time operation and we can simply wait for DeleteNoteUseCase
to send a new emission.
Finally, let’s fire off each event from the view:
@Composable
private fun Note(uiModel: Note) {
Row {
Checkbox(
checked = uiModel.isChecked,
onCheckedChange = { uiModel.events(Note.Event.OnCheck) }
)
TextField(
value = uiModel.text,
textStyle = TextStyle(
textDecoration = if (uiModel.isChecked) {
TextDecoration.LineThrough
} else {
TextDecoration.None
}
),
placeholder = { Text("Add note") },
onValueChange = { uiModel.events(Note.Event.OnUpdateText(it)) }
)
IconButton(onClick = { uiModel.events(Note.Event.OnDelete) }) {
Icon(imageVector = Icons.Default.Delete)
}
}
}
The Child
One of the nice things about Composable functions is that they can easily be broken down into smaller ones. This concept is no different with presenters: we can take a larger presenter and break it down into one or more smaller presenters. This is especially useful when writing code that needs to be reused across screens.
In the case of NotesListPresenter
, we can seamlessly extract the management of each note into its own presenter. This aligns nicely with the model too, since Note
is already nested inside of NotesUiModel
:
data class NotesUiModel(
val notes: List<Note>,
val events: EventHandler<Event>
) : NotesUiModel {
data class Note(
val text: String,
val isChecked: Boolean,
val events: EventHandler<Event>
) : UiModel {
// ...
}
// ...
}
With this in mind, we’ll create a NoteItemPresenter
that returns a NotesUiModel.Note
and accepts a NoteDataModel
as a parameter:
class NoteItemPresenter(
val updateNoteUseCase: UpdateNoteUseCase,
val deleteNoteUseCase: DeleteNoteUseCase
) : Presenter<Note, NoteItemPresenter.Params> {
data class Params(val note: NoteDataModel)
@Composable
override fun present(params: Params): Note {
val scope = rememberCoroutineScope()
val note = params.note
var text by remember(note.id) { mutableStateOf(note.text) }
var isChecked by remember(note.id) { mutableStateOf(note.isFinished) }
return Note(
text = text,
isChecked = isChecked,
events = EventHandler { event ->
when (event) {
Note.Event.OnCheck -> {
isChecked = isChecked.not()
scope.launch {
updateNoteUseCase(NoteDataModel(note.id, text, isChecked))
}
}
is Note.Event.OnUpdateText -> {
text = event.text
scope.launch {
updateNoteUseCase(NoteDataModel(note.id, text, isChecked))
}
}
Note.Event.OnDelete -> scope.launch {
deleteNoteUseCase(note.id)
}
}
}
)
}
}
The presentation logic is identical to what we had before inside the map
of NotesListPresenter
, but now we can just call the present()
function of NoteItemPresenter
for each note in the list:
class NotesListPresenter(
val noteItemPresenter: NoteItemPresenter,
// ...
) : Presenter<NotesUiModel, Unit> {
@Composable
override fun present(params: Unit): NotesUiModel {
// ...
return NotesUiModel.Data(
notes = notes.map { note ->
noteItemPresenter.present(NoteItemPresenter.Params(note))
},
events = EventHandler { event ->
// ...
}
)
}
}
Since each Note
is already broken out into its own Composable function in the view, we can easily reuse this view and its presenter on another screen.
The Test
Now that we have a fully working implementation, let’s write some tests for NotesListPresenter
. We’ll add the following plugins to help us with testing:
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'app.cash.molecule:molecule-gradle-plugin:0.9.0'
classpath 'com.jeppeman.mockposable:mockposable-gradle:0.4'
}
}
apply plugin: 'app.cash.molecule'
apply plugin: 'com.jeppeman.mockposable'
Since we’re testing Composable functions, we need a way to call them from the regular, non-Composable functions of our tests. Molecule bridges this gap by creating a flow that will emit the value returned by a Composable function each time it recomposes. We can then collect and assert on these values with the aid of Turbine, which comes packaged with Molecule.
Mockposable does what the name suggests: it allows us to mock the behavior of Composable functions. This is useful when we’re testing a presenter that calls another presenter, which is the case with NotesListPresenter
calling NoteItemPresenter
.
Note: These tests are also using mockk and kluent.
First, let’s write a test that verifies the notes are correctly loaded and we get back the model we’re expecting:
class NotesListPresenterTest {
@Test
fun `load and return notes`() {
TODO()
}
}
We’ll want to mock the behavior of ObserveNotesUseCase
to return a flow that emits a list of notes:
@Test
fun `load and return notes`() {
val observeNotesUseCase: ObserveNotesUseCase = mockk {
every { invoke() } returns flowOf(
listOf(
NoteDataModel(id = 1, text = "Note 1", isFinished = false),
NoteDataModel(id = 2, text = "Note 2", isFinished = true),
NoteDataModel(id = 3, text = "Note 3", isFinished = false)
)
)
}
}
Then we’ll want to mock the behavior of NoteItemPresenter
, which is responsible for transforming each NoteDataModel
into a NotesUiModel.Note
. As mentioned previously, we can use Mockposable to assist us with this:
@Test
fun `load and return notes`() {
// ...
val noteItemPresenter: NoteItemPresenter = mockk {
everyComposable { present(any()) } answersComposable {
val noteArg = (firstArg() as NoteItemPresenter.Params).note
Note(noteArg.text, noteArg.isFinished, EventHandler {})
}
}
}
Here we’re taking the argument passed in, which is a NoteItemPresenter.Params
, and forwarding the data of the note
property into a NotesUiModel.Note
.
Note: Since EventHandler
is stateless and doesn’t effect equality, we can simply use an empty one as a placeholder in the test.
Now that we have the dependencies mocked, we can instantiate NotesListPresenter
and invoke its present()
function with the aid of molecule:
@Test
fun `load and return notes`() {
// ...
val notesListPresenter = NotesListPresenter(
notePresesnter,
observeNotesUseCase,
createNoteUseCase = mockk() // not used by this test
)
moleculeFlow(RecompositionClock.Immediate) {
notesListPresenter.present(Unit)
}
}
moleculeFlow()
takes in a lambda that gives us a context for invoking the Composable present()
function and returns us a flow that will emit a new NotesUiModel
each time present()
recomposes. RecompositionClock.Immediate
allows us to control the execution of the Composable in a testable manner. To collect emissions, we call test()
on the flow that is returned by molecule:
@Test
fun `load and return notes`() = runTest {
// ...
moleculeFlow(RecompositionClock.Immediate) {
notesListPresenter.present(Unit)
}.test {
TODO()
}
}
The lambda passed to test()
gives us access to a Turbine for the flow, which we’ll use to consume emissions and assert the behavior of the present()
function.
Note: We surround the test with runTest()
since test()
is a suspend
function.
Recall that when invoking ObserveNotesUseCase
with launchUseCase()
, we provide it an initial value of emptyList()
:
val notes by observeNotesUseCase.launchUseCase(initial = emptyList())
Therefore, we can expect the first model emitted to contain no notes:
moleculeFlow(RecompositionClock.Immediate) {
notesListPresenter.present(Unit)
}.test {
awaitItem() shouldBeEqualTo NotesUiModel(emptyList(), EventHandler {})
}
After this first emission, we can then expect the list of notes we used when stubbing:
moleculeFlow(RecompositionClock.Immediate) {
notesListPresenter.present(Unit)
}.test {
// ...
awaitItem() shouldBeEqualTo NotesUiModel(
notes = listOf(
Note(text = "Note 1", isChecked = false, EventHandler {}),
Note(text = "Note 2", isChecked = true, EventHandler {}),
Note(text = "Note 3", isChecked = false, EventHandler {})
)
events = EventHandler {}
)
}
For a second test, let’s verify the behavior for adding a new note:
@Test
fun `add a new note`() = runTest {
val observeNotesUseCase: ObserveNotesUseCase = mockk {
every { invoke() } returns emptyFlow()
}
val createNoteUseCase: CreateNotUseCase = mockk(relaxedUnitFun = true)
val notesListPresenter = NotesListPresenter(
noteItemPresenter = mockk(), // not used by this test
observeNotesUseCase,
createNoteUseCase
)
moleculeFlow(RecompositionClock.Immediate) {
notesListPresenter.present(Unit)
}.test {
awaitItem().events.handle(Event.OnAdd)
coVerify { createNoteUseCase() }
}
}
Since we only need to verify that CreateNoteUseCase
is invoked, we can simply consume the first emission and then send Event.OnAdd
to its EventHandler
.
A similar set of tests can be written for NoteItemPresenter
and is left as an activity for the reader.
The Extras
Let’s assume that the loading of notes may take an indeterminate amount of time to load and in the meantime we want to show a loading indicator to the user. We can change NotesUiModel
to a sealed interface
and have it contain a Loading
and Data
type:
sealed interface NotesUiModel : UiModel {
object Loading : NotesUiModel
data class Data(
val notes: List<Note>,
val events: EventHandler<Event>
) : NotesUiModel {
// ...
}
}
If we update the launchUseCase()
extension function to support nullable types and use null
as the initial state, we can check for this to determine if the data has loaded or not:
@Composable
fun <T> FlowUseCase<T>.launchUseCase(initial: T? = null): State<T?> {
return produceState<T?>(initialValue = initial, producer = {
invoke().collect {
value = it
}
})
}
@Composable
override fun present(params: Unit): NotesUiModel {
// ...
val notesResult by observeNotesUseCase.launchUseCase()
val notes = notesResult ?: return NotesUiModel.Loading
// ...
}
You’ll notice that we have two variables now, notesResult
and notes
. The latter unwraps the delegate value and returns early if null
. This allows us to reference notes without treating it as a nullable value, which we can’t do when referencing the delegate value directly.
Note: By convention, we add “Result”
to the delegate variable name.
In the view, we can use a when
expression to unwrap the model returned by the presenter and decide if we want to show the notes or a loading indicator:
@Composable
fun NotesScreen() {
val presenter: NotesListPresenter = koinInject()
val uiModel = presenter.present(Unit)
Column {
TopAppBar(title = { Text("Notes") })
when (uiModel) {
is TodoUiModel.Data -> Notes(uiModel)
TodoUiModel.Loading -> LoadingProgressIndicator()
}
}
}
To further enrich the user experience, let’s add an empty note when there are no existing notes to show:
@Composable
override fun present(params: Unit): NotesUiModel {
// ...
// create an initial note if there are none
LaunchedEffect(Unit) {
if (notes.isEmpty()) {
createNoteUseCase()
}
}
// ...
}
The LaunchedEffect
is key’d with Unit
to ensure that the check and the subsequent call to createNoteUseCase()
only executes once inside of present()
. This prevents us from needlessly adding a note after the user has deleted the last remaining one in the list.
Lastly, you’ll recall that we don’t emit changes to the flow of notes with UpdateNoteUseCase
, as this causes bugs when the user rapidly enters input. We’ll want to ensure these emissions happen when the user leaves the screen though, otherwise the changes would be lost when returning to it from another screen. To do this, we’ll need to listen for Lifecycle events, which we can do with the following Composable function:
@Composable
fun LifecycleEffect(onEvent: (Lifecycle.Event) -> Unit) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
DisposableEffect(lifecycleOwner.value) {
val lifecycle = lifecycleOwner.value.lifecycle
val observer = LifecycleEventObserver { _, event ->
onEvent(event)
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
}
}
}
Then we can call LifecycleEffect()
from the view and listen for Lifecycle.Event.ON_PAUSE
:
@Composable
private fun Notes(uiModel: NotesUiModel.Data) {
LifecycleEffect {
if (it == Lifecycle.Event.ON_PAUSE) {
uiModel.events.handle(Event.OnSave)
}
}
// ...
}
Note: We choose to do this in the view and then communicate it to the presenter via an event, as it makes testing the presenter easier.
To handle the event in the presenter, we can’t rely on rememberCoroutineScope()
like we’ve done with the other events. Since we’re performing the save operation as the view is being paused (and possibly destroyed), we need to ensure the operation has time to complete. For this type of scenario we’ll introduce an AppScope
class that will exist for the lifetime of the app:
class AppScope(
dispatcher: CoroutineDispatcher = Dispatchers.Main
) : CoroutineScope, DefaultLifecycleObserver {
override val coroutineContext = SupervisorJob() + dispatcher
init {
this.launch(dispatcher) {
ProcessLifecycleOwner.get().lifecycle.addObserver(this@AppScope)
}
}
override fun onDestroy(owner: LifecycleOwner) {
coroutineContext.cancelChildren()
}
}
Note: This scope is preferable to using GlobalScope
. For more details on why, refer to this article.
We can then inject AppScope
into NotesListPresenter
, along with SaveNotesUseCase
, and properly handle Event.OnSave
:
class NotesListPresenter(
// ...
val appScope: AppScope,
val saveNotesUseCase: SaveNotesUseCase
) : Presenter<NotesUiModel, Unit> {
@Composable
override fun present(params: Params): NotesUiModel {
// ...
return NotesUiModel.Data(
notes = notes.map { note ->
// ...
},
events = EventHandler { event ->
when (event) {
Event.OnAdd -> // ...
Event.OnSave -> appScope.launch {
saveNotesUseCase()
}
}
}
)
}
}
For testing, we can inject Dispatchers.Unconfined
into AppScope
, which ensures the code executes in a blocking manner.
The End
This completes the case study of the Notes app. This should give you a realistic and thorough example of using Compose to manage the presentation logic of an app. A full code sample is provided as a Gist.
Be sure to follow @doximity_tech if you'd like to be notified about new blog posts.