Home Android & Kotlin Tutorials

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.

4.8/5 6 Ratings

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.

Note: This Jetpack Compose tutorial requires intermediate knowledge of Kotlin as well as some basic knowledge of Jetpack Compose. You can freshen up on the basics of Compose with the Jetpack Compose Tutorial for Android.

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:


Project structure

Sync the project. Then, build and run. The app will look like this:


Initial app state

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:


Simple repository implementation

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:


User Repository with Remote and Local sources

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:


User 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 = {

    }
  )
}
Note: When copying the code in this tutorial, you can use alt+Enter to auto import the components.

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:

  1. Define a Row that displays elements horizontally.
  2. Add a modifier to capture clicks and forward them to the onClick callback.
  3. Include padding in the layout so the content has breathing room.
  4. 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:

  1. Creates a LazyColumn.
  2. Tells LazyColumn to render a list of words.
  3. 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:

  1. Converting the list of strings into a list of words.
  2. 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:


List of gibberish 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:

  1. Defining the ViewModel as an AndroidViewModel with an associated application instance. You're not using the application now, but you'll use it later to inject components.
  2. 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:

  1. Added a new parameter, words, to WordListUi.
  2. Passed the list of words to WordsContent. Remember, the word generation is now in MainViewModel.

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:

  1. Making AppHttpClient the default value for the HttpClient.
  2. Using withContext to make sure your code runs in the background, not in the main thread.
  3. Loading all the words as a string using getRemoteWords. This is an extension function that you'll define later.
  4. Reading all lines as a sequence.
  5. Converting each line into a Word.
  6. 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:


List of words in the app

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 MainViewModel to use 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:

  1. Creating an internal MutableState which hosts the list of Words, which is empty right now.
  2. Exposing the MutableState as a non-mutable State.
  3. Adding a function to load the list of words.
  4. 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 the ViewModel 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:

  1. The ViewModel starts loading all the words by calling load.
  2. You consume the words using delegation. Any new updates from the ViewModel come here and trigger a layout recomposition.
  3. 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:


App screen with loading indicator spinning

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:

  1. The Entity annotation tells Room the name of the entity's table.
  2. 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:

  1. @Dao indicates that this interface is a DAO.
  2. queryAll uses the @Query annotation to define a Sqlite query. The query asks for all the values to be ordered by the value property.
  3. insert adds or update words to the database.
  4. 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:

  1. Saves an internal instance of WordDao as words.
  2. Calls all using WordDao to access LocalWord instances. Then, map converts them to plain Words.
  3. Takes a list of plain Words using save, converts them to Room values and saves them.
  4. 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.

  1. For ensureIsNotEmpty to work, you added WordStore as a constructor property.
  2. For convenience, you added a secondary constructor. It recieves a database which is then used to create WordStore.
  3. Then, you called ensureIsNotEmpty before calling the all 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.

  1. First, you want to define a Room database of type AppDatabase called database.db. You have to make it lazy because your app doesn't yet exist while you're instantiating the database in this.
  2. 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 get LoadResult instances using load.
  • LoadParams: tells the PagingSource 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 a PagingSource to a Flow of PagingData.
  • 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:

  1. Collecting the pages into LazyPagingItems instance. LazyPagingItems manages page loading using coroutines.
  2. Overloading the items function with the Paging library. This new version takes LazyPagingItems instead of a plain List of items.
  3. 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.

A screenshot of the app that hasn't changed since the last step.

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:


Search Bar appears at the top of the app screen

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:

  1. words is deciding whether to use allWords or searchWords depending on if the search is null or empty.
  2. You're using flatMapLatest to return a Flow instead of a StateFlow. With stateIn, you can return the Flow as a StateFlow. The returned Stateflow is bound to viewModelScope. Then, it waits for a collector before emitting any values. It also provides an initial value.
  3. 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":


Search for 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.


Search with no results

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!

Average Rating

4.8/5

Add a rating for this content

6 ratings

More like this

Contributors

Comments