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. By Pablo Gonzalez Alonso.

4.7 (14) · 6 Reviews

Download materials
Save for later
Share
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

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.