Repository Pattern with Jetpack Compose
In this tutorial, you’ll learn how to combine Jetpack Compose and the repository pattern, making your Android code easier to read and more maintainable.
Version
- Kotlin 1.5, Android 7.0, Android Studio 2020.3.1

On Android, you used to always have to create user interface layouts using XML. But in 2019, Google introduced a fresh, new approach to building user interfaces: Jetpack Compose. Compose uses a declarative API to build UI with the power of Kotlin.
In this tutorial, you’ll combine the power of Jetpack Compose with the repository pattern to build an English dictionary app.
You’ll need to install Android Studio Arctic Fox to work with Jetpack Compose. Note that this is the first stable release of Android Studio supporting Jetpack Compose.
While building your dictionary app, you’ll learn to:
- Read and display remote data.
- Persist and restore local data with Room.
- Use pagination with LazyColumn.
- Manage and update UI States with Compose.
You’ll see how Jetpack Compose really shines by eliminating the need for RecyclerView and simplifying the state management. OK. It’s time to start!
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.
Open the project with Android Studio. You’ll see the following file structure:
Sync the project. Then, build and run. The app will look like this:
As you can see, it’s nothing too flashy, yet. :] Before diving into the code to make your app flashier (err, I mean, more useful), you’ll learn a few things about the repository pattern.
The Repository Pattern
A repository defines data operations. The most common operations are creating, reading, updating, and deleting data, (also known as CRUD). These operations sometimes need parameters that define how to run them. For instance, a parameter could be a search term to filter results.
The repository pattern is a structural design pattern. It’s instrumental for organizing how you access data. It also helps divide concerns into smaller parts. For more information and terminology, check out Clean Architecture for Android: Getting Started.
The repository pattern was first introduced in 2004 by Eric Evans in his book, Domain-Driven Design: Tackling Complexity in the Heart of Software.
You’ll be implementing the repository pattern with Jetpack Compose. The first step is add the datasource. You’ll learn about this next.
Understanding Datasources
Repository operations delegate to a relevant datasource. Datasources can be remote or local. The repository operation has logic that determines the relevance of a given datasource. For instance, the repository can provide a value from a local datasource or ask a remote datasource to load from the network.
Stores and Sources are two of the most important types of datasources. Stores get their data from local sources and Sources get their data from remote sources. The following illustration shows what a simple repository implementation looks like:
Using a Repository
When would you need to use a repository? Well, imagine that your app’s user wants to see their profile. The app has a repository that checks the Store for a local copy of the user’s profile. If the local copy isn’t present, then the repository checks with the remote Source. Implementing this kind of repository looks like this:
By the end of this tutorial, you’ll use the repository pattern with both Store and Source datasources. In other words, your app will use both remote and local data to populate and store the words.
Other datasources may rely on different types of Sources like Location Services, Permission Results or Sensor inputs.
For instance, the user repository can include two additional data sources: One to verify the user’s authorization and another for an in-memory cache. The first one is useful if you need to make sure the user can see the profile, while the second one is helpful when accessing an entity often since you may not want the app to read from the database every time. Here’s a simple illustration of a repository with authorization and in-memory cache:
One benefit of the repository pattern is that it’s straightforward to add new layers of functionality. And, at the same time, repositories keep concerns separated and organize logic into components. These logical components also need less responsibility. This keeps the code concise and decoupled.
Ok, that’s enough theory for now. Time for some coding fun. :]
Creating a UI for Words
Now it’s time to create the UI for your app, Words.
Create a file called WordListUi.kt in the UI package. Inside the file, define WordListUi
with a basic Scaffold
:
@Composable
fun WordListUi() {
Scaffold(
topBar = { MainTopBar() },
content = {
}
)
}
Now, open MainActivity.kt and replace the Scaffold in onCreate
with WordListUi()
:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WordsTheme {
WordListUi()
}
}
}
Now the Scaffold
defined in WordListUi
is displayed when the app is launched inside the main activity.
Before building more UI elements, you’ll create the model that defines each word. In the package data.words, add a new data class, Word.kt with the following code:
data class Word(val value: String)
Then, in WordsListUi.kt, define a Composable below WordListUi
to show a word as a list item:
@Composable
private fun WordColumnItem(
word: Word,
onClick: () -> Unit,
) {
Row( // 1
modifier = Modifier.clickable { onClick() }, // 2
) {
Text(
modifier = Modifier.padding(16.dp), // 3
text = word.value, // 4
)
}
}
By doing this, you’re setting the WordColumnItem
Composable to:
- Define a Row that displays elements horizontally.
- Add a modifier to capture clicks and forward them to the
onClick
callback. - Include padding in the layout so the content has breathing room.
- Use the value of the word as the text.
Next, you’ll create a Composable to display a list of words.
To do this in Compose, add the following composable to the bottom of WordListUi.kt:
@Composable
private fun WordsContent(
words: List<Word>,
onSelected: (Word) -> Unit,
) {
LazyColumn { // 1
items(words) { word -> // 2
WordColumnItem( // 3
word = word
) { onSelected(word) }
}
}
}
The above code:
- Creates a
LazyColumn
. - Tells
LazyColumn
to render a list of words. - Creates a
WordColumnItem
for each of the items.
LazyColumn
renders the items as the user scrolls.
This is so much simpler than RecyclerView
and ListView! Where have you been all our lives, LazyColumns
? :]
To test the layout, use RandomWords
. Add the following inside of content
inWordListUi:
WordsContent(
words = RandomWords.map { Word(it) }, // 1
onSelected = { word -> Log.e("WordsContent",
"Selected: $word") } // 2
)
The two main things you’re doing here are:
- Converting the list of strings into a list of
words
. - Printing a message to Logcat to verify button taps.
Now, build and run. Since you used RandomWords
to test the layout, you'll see a list of random words:
It's gibberish, but it gives you a rough idea of how your app will look.
Next, you'll create a ViewModel for the main screen and a repository for Words
.
Creating the Main ViewModel
ViewModel is an architecture component from Android Jetpack. ViewModel's
primary feature is to survive configuration changes, like rotation.
Create MainViewModel.kt in a new file within the package com.raywenderlich.android.words:
// 1
class MainViewModel(application: Application) : AndroidViewModel(application) {
// 2
val words: List<Word> = RandomWords.map { Word(it) }
}
In this ViewModel
, you're:
- Defining the
ViewModel
as anAndroidViewModel
with an associated application instance. You're not using the application now, but you'll use it later to inject components. - Returning the same values that you currently have in
WordListUi
.
Next, get MainViewModel
in MainActivity.kt with delegation. Add the following line of code inside MainActivity above onCreate
:
private val viewModel by viewModels<MainViewModel>()
The framework automatically injects the current application instance into MainViewModel
.
Now, you'll prepare WordListUi
to receive the data. Replace WordListUi
with:
@Composable
fun WordListUi(words: List<Word>) { // 1
Scaffold(
topBar = { MainTopBar() },
content = {
WordsContent(
words = words, // 2
onSelected = { word -> Log.e("WordsContent",
"Selected: $word") }
)
}
)
}
With this code, you:
- Added a new parameter,
words
, toWordListUi
. - Passed the list of words to
WordsContent
. Remember, the word generation is now inMainViewModel
.
Next, go to MainActivity and populate the word list with the words
from the viewModel
:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WordsTheme {
WordListUi(words = viewModel.words)
}
}
}
If you run the app, everything will look the same as before. But now the app persists the components between configuration changes. Isn't that a great feeling? :] Now that the ViewModel
is in place, it's time to build the repository.
Building the WordRepository
Next, you'll create the WordRepository
and collaborators, starting with the remote data source.
To load data from the internet, you'll need a client. Create a file named AppHttpClient.kt in the data package. Then, add a top-level property called AppHttpClient
:
val AppHttpClient: HttpClient by lazy {
HttpClient()
}
This code lazily initializes a Ktor client for triggering HTTP requests.
Next, within the package data.words, create a new package, remote, and create a file named WordSource.kt. Then, add the following code to it:
// 1
class WordSource(private val client: HttpClient = AppHttpClient) { // 2
suspend fun load(): List<Word> = withContext(Dispatchers.IO) {
client.getRemoteWords() // 3
.lineSequence() // 4
.map { Word(it) } // 5
.toList() // 6
}
}
The code above is:
- Making
AppHttpClient
the default value for theHttpClient
. - Using
withContext
to make sure your code runs in the background, not in the main thread. - Loading all the words as a string using
getRemoteWords
. This is an extension function that you'll define later. - Reading all lines as a sequence.
- Converting each line into a
Word
. - Converting the sequence into a list.
Next, add the following code below the WordSource
declaration:
private suspend fun HttpClient.getRemoteWords(): String =
get("https://pablisco.com/define/words")
This extension function executes a network GET request on an HttpClient
. There are many get
overloads, so make sure you import this exact one:
import io.ktor.client.request.*
Now, create a new class called WordRepository.kt under the package data.words. Then, add the following code to it:
class WordRepository(
private val wordSource: WordSource = WordSource(),) {
suspend fun allWords(): List<Word> = wordSource.load()
}
WordRepository
uses WordSource
to get the complete list of words.
Now that the repository is ready, open WordsApp.kt and add it inside the class as a lazy property:
val wordRepository by lazy { WordRepository() }
Then, replace the body of MainViewModel
with:
private val wordRepository =
getApplication<WordsApp>().wordRepository
val words: List<Word> = runBlocking { wordRepository.allWords() }
Build and run. After a short wait, you'll see a list of words that loaded from the network:
With the repository in place, it's time to manage the UI State with Jetpack Compose.
Working With State in Compose
Compose has two complementary concepts: State and MutableState. Take a look at these two interfaces that define them:
interface State<out T> {
val value: T
}
interface MutableState<T> : State<T> {
override var value: T
}
Both provide a value but MutableState
also lets you update the value. Compose watches changes in these states. An update on these states triggers a recomposition. Recomposition is a bit like the way old-fashioned Views used to get redrawn when the UI needed an update. However, Compose is smart enough to redraw and update the Composables that rely on a changeable value when the value changes.
Keeping all that in mind, update State
instead of only List
:
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val wordRepository = getApplication<WordsApp>().wordRepository
private val _words = mutableStateOf(emptyList<Word>()) // 1
val words: State<List<Word>> = _words // 2
fun load() = effect {
_words.value = wordRepository.allWords() // 3
}
private fun effect(block: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { block() } // 4
}
}
With these changes, you're:
- Creating an internal
MutableState
which hosts the list ofWords
, which is empty right now. - Exposing the
MutableState
as a non-mutable State. - Adding a function to load the list of words.
- Adding a utility function to launch operations in the
ViewModel's
coroutine's scope. Using this scope, you can make sure the code only runs when theViewModel
is active and not on the main thread.
Now, in MainActivity.kt, update the content of the main activity. Replace the code in onCreate
with:
super.onCreate(savedInstanceState)
viewModel.load() // 1
setContent {
val words by viewModel.words // 2
WordsTheme {
WordListUi(words = words) // 3
}
}
Here is what's happening:
- The
ViewModel
starts loading all the words by callingload
. - You consume the words using delegation. Any new updates from the
ViewModel
come here and trigger a layout recomposition. - You can now give the words to
WordListUi
.
All this means that the UI will react to new words after calling load()
.
Next, you'll get a bit of a theory break as you learn about Flows and how they'll feature in your app.
Upgrading State to Flow
Exposing State instances from the ViewModel, as the app is doing now, makes it depend too much on Compose. This dependency makes it hard to move a ViewModel to a different module that doesn't use Compose. For example, moving a ViewModel would be difficult if you share logic in a Kotlin Multiplatform module. Creating a coroutine solves this dependency issue because you can use StateFlow instead of State.
Flows, which live in the coroutines library, are a stream of values consumed by one or many components. They're cold by default, which means that they start producing values only when consumed.
SharedFlow is a special type of flow: a hot flow. This means that it emits a value without a consumer. When a SharedFlow emits a new value, a replay cache keeps it, re-emitting the SharedFlow to new consumers. If the cache is full, it drops old values. By default, the size of the cache is 0.
There is a special type of SharedFlow called StateFlow. It always has one value, and only one. Essentially, it acts like States in Compose.
In the next steps, you'll utilize StateFlow to deliver the updated results to the UI and improve the structure of the app.
Using StateFlow to Deliver Results to the UI
To update the app to use StateFlow
, open MainViewModel.kt and change State
from Compose to StateFlow. Also change mutableStateOf
to MutableStateFlow
. The code should then look like:
private val _words = MutableStateFlow(emptyList<Word>())
val words: StateFlow<List<Word>> = _words
State
and StateFlow
are very similar, so you don't have to update much of the existing code.
In MainActivity.kt, convert StateFlow
to a Compose State
using collectAsState
:
val words by viewModel.words.collectAsState()
Now, MainViewModel
has no dependencies to Compose. Next, the app needs to display a loading state while the data loads.
Showing a Loading State
Right now, the word list loads slowly. But you don't want your users to stare at an empty screen during loading! So, you'll create a loading state to give them visual feedback while they wait.
Start by creating a StateFlow
in MainViewModel.kt by adding the following to the top of MainViewModel
:
private val _isLoading = MutableStateFlow(true) val isLoading: StateFlow<Boolean> = _isLoading
isLoading
represents whether the app is loading or not. Now, update the _isLoading value before and after loading the words from the network. Replace load
with:
fun load() = effect { _isLoading.value = true _words.value = wordRepository.allWords() _isLoading.value = false }
With the code above, you're setting the state as "loading" first and resolving it as "not loading" once it's finished loading all words from the repository.
Use isLoading
inside MainActivity.kt to display the appropriate UI state. Update the code inside of setContent
just below the declaration of words
with:
val isLoading by viewModel.isLoading.collectAsState() WordsTheme { when { isLoading -> LoadingUi() else -> WordListUi(words) } }
Here, if the state is loading, Compose will render LoadingUi
instead of WordListUi
.
Run the app again and you'll see that it now has a loading indicator:
The new loading indicator looks great! However, does the app need to load all the words from the network each time? Not if the data is cached in the local datastore.
Storing Words With Room
The words load slowly right now because the app is loading all the words every time the app is run. You don't want your app to do this!
So, you'll build a Store for the words loaded from the network using Jetpack Room.
To get started, create a package called local in data.words. Then, create a class called LocalWord.kt in the data.words.local package:
@Entity(tableName = "word") // 1
data class LocalWord(
@PrimaryKey val value: String, // 2
)
The local representation has the same structure as Word
but with two key differences:
- The Entity annotation tells Room the name of the entity's table.
- Every Room entity must have a primary key.
Next, define a Data Access Object (DAO) for Word
called WordDao.kt in local:
@Dao // 1
interface WordDao {
@Query("select * from word order by value") // 2
fun queryAll(): List<LocalWord>
@Insert(onConflict = OnConflictStrategy.REPLACE) // 3
suspend fun insert(words: List<LocalWord>)
@Query("select count(*) from word") // 4
suspend fun count(): Long
}
With the code above, you've defined four database operations with Room:
-
@Dao
indicates that this interface is a DAO. -
queryAll
uses the@Query
annotation to define a Sqlite query. The query asks for all the values to be ordered by the value property. -
insert
adds or update words to the database. -
count
finds out if the table is empty.
Now, you'll create a database in a new file called AppDatabase.kt in data.words so Room can recognize the Entity and DAO:
@Database(entities = [LocalWord::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract val words: WordDao
}
This abstract database defines LocalWord
as the only entity. It also defines words as an abstract property to get an instance of WordDao
.
The Room compiler generates all the bits that you need for this to work. How nice! :]
Now that AppDatabase
is ready, your next step is to utilize the Dao in a store. Create WordStore
in a new file called WordStore.kt in data.words.local:
class WordStore(database: AppDatabase) {
// 1
private val words = database.words
// 2
fun all(): List<Word> = words.queryAll().map { it.fromLocal() }
// 3
suspend fun save(words: List<Word>) {
this.words.insert(words.map { it.toLocal() })
}
// 4
suspend fun isEmpty(): Boolean = words.count() == 0L
}
private fun Word.toLocal() = LocalWord(
value = value,
)
private fun LocalWord.fromLocal() = Word(
value = value,
)
The mapper functions, toLocal
and fromLocal
, convert Word
from and to LocalWord
.
The code above does the following to WordStore
:
- Saves an internal instance of
WordDao
aswords
. - Calls
all
usingWordDao
to accessLocalWord
instances. Then,map
converts them to plainWords
. - Takes a list of plain
Words
usingsave
, converts them to Room values and saves them. - Adds a function to determine if there are any saved words.
Since you have added the code to save words to the database, the next step is to update WordRepository.kt to use this code. Replace WordRepository
with:
class WordRepository(
private val wordSource: WordSource,
// 1
private val wordStore: WordStore,
) {
// 2
constructor(database: AppDatabase) : this(
wordSource = WordSource(),
wordStore = WordStore(database),
)
// 3
suspend fun allWords(): List<Word> =
wordStore.ensureIsNotEmpty().all()
private suspend fun WordStore.ensureIsNotEmpty() = apply {
if (isEmpty()) {
val words = wordSource.load()
save(words)
}
}
}
One key component here is the extension function ensureIsNotEmpty
. It populates the database in WordStore
if it's empty.
- For
ensureIsNotEmpty
to work, you addedWordStore
as a constructor property. - For convenience, you added a secondary constructor. It recieves a database which is then used to create
WordStore
. - Then, you called
ensureIsNotEmpty
before calling theall
function to make sure the store has data.
Update WordsApp
with a private database and a public wordRepository
to work with the newly updated WordRepository
. Replace the body of WordsApp
with:
// 1
private val database by lazy {
Room.databaseBuilder(this, AppDatabase::class.java,
"database.db").build()
}
// 2
val wordRepository by lazy { WordRepository(database) }
Each Android process creates one Application object, and only one. This is one place to define singletons for manual injection, and they need an Android context.
- First, you want to define a Room database of type
AppDatabase
calleddatabase.db
. You have to make it lazy because your app doesn't yet exist while you're instantiating the database inthis
. - Then, define an instance of
WordRepository
with the database you just created in the previous step. You also need to make this lazy to avoid instantiating the database too early.
Build and run. You'll see that it still takes a long time to load the first time you run it, but after that, the words will load immediately each time the app is launched.
The next thing you'll tackle is making sure you don't load thousands of words into memory. This can cause a problem when large datasets collide with devices that have low memory. It would be best to only keep the words that are being displayed, or about to be displayed, in memory.
Adding Pagination
To avoid loading all the possible words that exist in a dictionary into memory instead of just the ones currently being viewed, you'll add pagination to your app.
The Jetpack Paging 3 library has a companion library for Compose made for this purpose. There are a few important concepts in this library for you to understand before moving forward:
-
PagingSource: uses
LoadParams
to getLoadResult
instances usingload
. -
LoadParams
: tells thePagingSource
how many items to load and also includes a key. This key is usually the page number but could be anything. -
LoadResult
: a sealed class that tells you if there is a page or if an error happened while loading it. -
Pager
: a convenience utility that helps you convert aPagingSource
to a Flow ofPagingData
. -
PagingData
: the final representation of a page that you're going to use in the UI.
Luckily, Room works well with Jetpack Paging 3 and has built in functionality for it. So, you can edit queryAll
in WordDao.kt to enable pagination:
@Query("select * from word order by value") fun queryAll(): PagingSource<Int, LocalWord>
Open WordStore.kt and you'll see that the compiler isn't happy with the syntax in all
. You'll fix this next.
Add the following code to the bottom of WordStore.kt:
private fun pagingWord( block: () -> PagingSource<Int, LocalWord>, ): Flow<PagingData<Word>> = Pager(PagingConfig(pageSize = 20)) { block() }.flow .map { page -> page.map { localWord -> localWord.fromLocal() } }
Here, you're using Pager
to convert a PagingSource
to a Flow
of PagingData
. A nested map converts each PagingData
's LocalWords
to regular Word
instances.
With pagination in place, you can update all
:
fun all(): Flow<PagingData<Word>> = pagingWord { words.queryAll() }
You need to update the code in a few more places to avoid compilation errors.
In WordRepository.kt, update allWords
so that it returns a Flow
instead of List
:
suspend fun allWords(): Flow<PagingData<Word>> = ...
Notice that you can also remove the return type and let the compiler interpret the type.
Now, open MainViewModel.kt and update the following declarations:
private val _words = MutableStateFlow(emptyFlow<PagingData<Word>>()) val words: StateFlow<Flow<PagingData<Word>>> = _words
Next, in WordListUi.kt update WordListUi
to receive a Flow
instead of a List
:
fun WordListUi(words: Flow<PagingData<Word>>) { ... }
To make words work with a LazyColumn
, you have to change how you collect the words. Update the body of WordsContent
as follows:
private fun WordsContent( words: Flow<PagingData<Word>>, onSelected: (Word) -> Unit, ) { // 1 val items: LazyPagingItems<Word> = words.collectAsLazyPagingItems() LazyColumn { // 2 items(items = items) { word -> // 3 if (word != null) { WordColumnItem( word = word ) { onSelected(word) } } } } }
You're doing three new things here:
- Collecting the pages into
LazyPagingItems
instance.LazyPagingItems
manages page loading using coroutines. - Overloading the
items
function with the Paging library. This new version takesLazyPagingItems
instead of a plain List of items. - Checking if the item is null or not. Note that if you have placeholders enabled, the value may be null.
Build and run the app. You'll see that it works the same as before. However, the performance has been improved because now the app does not store the entire list of words in the memory all at once.
Searching the Dictionary
You've loaded a list of words into your app, but a list of words isn't useful on its own. Just try scrolling to find a word that starts with B. It takes a while. You need to give your users a way to search for words.
To do this, you'll first need to be able to represent the current search query in MainViewModel.kt. Add the following inside MainViewModel
at the top:
private val _search = MutableStateFlow(null as String?) val search: StateFlow<String?> = _search fun search(term: String?) { _search.value = term }
A private StateFlow
, called _search, holds the current query. When someone calls search
, it will send updates to collectors.
Next, you have to update WordListUi
parameters as follows:
fun WordListUi( words: Flow<PagingData<Word>>, search: String?, onSearch: (String?) -> Unit, )
Here, you added the string to search for and a callback to trigger the actual search.
Inside WordListUi
, replace the MainTopBar
with a SearchBar
:
topBar = { SearchBar( search = search, onSearch = onSearch, ) }
The SearchBar Composable isn't built-in to the Jetpack libraries, but it's included in the starter project if you want to check it out. You can find it in ui.bars.
In MainActivity.kt, add the following inside setContent
at the top to collect the search state as follows:
val search by viewModel.search.collectAsState()
Then, update the call to WordListUi
. Pass the search term and search function from the ViewModel
:
WordListUi( words = words, search = search, onSearch = viewModel::search )
Build and run. You'll see a new top bar with a search icon. Click the icon to expand the search input field:
At this point, your search function doesn't respond to typing in a search term. You'll address this issue now.
Reacting to Searches
To make your search function fully functional, you need to retrieve the data and update the UI for each search. To do that, you'll add searchAll
to WordDao:
@Query("select * from word where value like :term || '%' order by value") fun searchAll(term: String): PagingSource<Int, LocalWord>
The key difference between searchAll
and the previous function, queryAll
, is the where
condition. Take a closer look:
where value like :term || '%'
where
filters words that start with a given :term
string.
Next, add all
in WordStore.kt to use searchAll
:
fun all(term: String): Flow<PagingData<Word>> = pagingWord { words.searchAll(term) }
In WordRepository.kt, add this overload of allWords
as follows:
suspend fun allWords(term: String): Flow<PagingData<Word>> = wordStore.ensureIsNotEmpty().all(term)
Basically, you're passing a term
to the all
function. As before, use ensureIsNotEmpty
to make sure the Store isn't empty.
Next, you need to make sure the app can show the current search results. Start by adding the following code in MainViewModel.kt inside MainViewModel
at the top:
private val allWords = MutableStateFlow(emptyFlow<PagingData<Word>>()) private val searchWords = MutableStateFlow(emptyFlow<PagingData<Word>>())
Using the code above, you're declaring two separate MutableStateFlow
properties: one for all words and another for searched words.
Next, update load
so it uses allWords
instead of _words
. The code will look like this:
fun load() = effect { _isLoading.value = true allWords.value = wordRepository.allWords() _isLoading.value = false }
Now, find the place at the top of MainViewModel
where you declare words
:
val words: StateFlow<Flow<PagingData<Word>>> = _words
Replace words with the following:
@OptIn(ExperimentalCoroutinesApi::class) val words: StateFlow<Flow<PagingData<Word>>> = search .flatMapLatest { search -> words(search) } .stateInViewModel(initialValue = emptyFlow())
The compiler will not recognize words yet, but you'll fix that in a bit.
Here, you're using the search StateFlow
to generate a new Flow
. The new
Flow
selects allWords
if there's no search request or searchWords
if there is a search request. This is thanks to flatMapLatest
.
Since you're not using _words
anymore, you can delete it.
Finally, add the following functions at the bottom of MainViewModel
:
// 1 private fun words(search: String?) = when { search.isNullOrEmpty() -> allWords else -> searchWords } // 2 private fun <T> Flow<T>.stateInViewModel(initialValue : T): StateFlow<T> = stateIn(scope = viewModelScope, started = SharingStarted.Lazily, initialValue = initialValue) fun search(term: String?) = effect { _search.value = term // 3 if (term != null) { searchWords.value = wordRepository.allWords(term) } }
Delete the old version of search
.
Here's what's happening in your app now that you've added the code above:
-
words
is deciding whether to useallWords
orsearchWords
depending on if the search is null or empty. - You're using
flatMapLatest
to return aFlow
instead of aStateFlow
. WithstateIn
, you can return theFlow
as aStateFlow
. The returnedStateflow
is bound toviewModelScope
. Then, it waits for a collector before emitting any values. It also provides an initial value. - If the search term isn't null, your app will update
searchWords
with the new term.
Build and run to test your hard work building the search function. Relaunch the app and open the search input field. Search for a word like "Hello":
Hooray! Your search function works; it filters out all the other words and only shows the word you searched for.
Showing an Empty Search Result
Right now, if your search produces no results, the screen will be blank. But it would be nicer to give the user some feedback instead of a blank screen. So, you'll implement a screen that will tell the user that their search came up empty.
First, add the following Composable at the end of WordlistUi.kt:
@Composable private fun LazyItemScope.EmptyContent() { Box( modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center, ) { Text(text = "No words") } }
This is a simple Composable that shows a Text
. You'll use it when there are no search results. The Composable extends from LazyItemScope
. This means you can use fillParentMaxSize
instead of fillMaxSize
. Doing so guarantees that the layout fills the size of the LazyColumn
.
Then, in the LazyColumn
in WordsContent
, call item
if there are no items. Inside the bottom of the LazyColumn
, use EmptyContent
to show an empty message:
if(items.itemCount == 0) { item { EmptyContent() } }
Build and run. Now, there's a screen that clearly shows the user there are no results for their search.
Finally, you've finished your app! Your users can look up the definition of English words. Your app will help people learn English and win at Scrabble. :]
Where to Go From Here?
You can download the final project by clicking the Download Materials button at the top or bottom of the tutorial.
By now, you see how easy it is to create declarative user interfaces with Compose and how the repository pattern complements this design.
If you want to learn more about Compose, check out the official tutorial and documentation from Google or our very own JetPack Compose by Tutorials.
If you want to learn what else is possible with Room, start with this tutorial about Data Persistence With Room or the official documentation, Save Data in a Local Database Using Room.
As a casual challenge, you can also try to extend the sample app's functionality so that your users can save their favorite words.
We hope you enjoyed playing with Compose and the repository pattern. If you have any questions, comments or ideas for things you'd like to see done with Compose, please join the forum discussion below!
Comments