Room DB pagination without Paging Android Jetpack Compose
Room DB is a popular library for working with SQLite databases in Android. One of the challenges of using Room DB is how to handle large datasets that cannot fit in memory or on the screen. This is where pagination comes in. In this post, we are going to add pagination to our Room DB without Paging library.
Data
I suppose that you already have a DB created and a basic implementation (The Entity, DAO, TypeConverter, etc ). So, I have an Interface NoteDao with some database interactions.
Here we are going to add another interaction to get the notes paginated.
@Query("SELECT * FROM note WHERE subject_id == :id ORDER BY note_date DESC LIMIT :limit OFFSET :offset")
suspend fun getPagingNotesOrderByDate(id: Int, limit: Int, offset: Int): List<Note>
This looks like a normal Room query impl, the special thing here is that we are using the LIMIT and OFFSET SQL clauses.
The LIMIT specifies the maximum number of rows that the query can return. The OFFSET clause specifies how many rows to skip before returning the result set. In this syntax, you need to use an ORDER BY clause to ensure the order of the rows in the result set.
This is all we have to do with our DB, the next step is to use our DAO in the Repository.
Repository
This is another simple peace of code.
override suspend fun getPagingNotesByDate(subjectID: Int, limit: Int, offset: Int): List<Note> {
return notesDao.getPagingNotesOrderByDate(subjectID, limit, offset)
}
You can define Your own Repository and logic, mine are defined in this way.
This is all for our repo, the next step is adding the ViewModel to handle the pagination.
ViewModel
We can begin creating a simple ViewModel structure and an enum class to manage our paging state.
@HiltViewModel
class PagingRecordsViewModel @Inject constructor(
private val notesRepositoryImpl: NotesRepository,
private val dispatcher: CoroutineDispatcher,
) : ViewModel() {
}
enum class PaginationState {
REQUEST_INACTIVE,
LOADING,
PAGINATING,
ERROR,
PAGINATION_EXHAUST,
EMPTY,
}
The enum class PaginationState
has six constants: REQUEST_INACTIVE
, LOADING
, PAGINATING
, ERROR
, PAGINATION_EXHAUST
and EMPTY
. These constants represent different states of a pagination process.
REQUEST_INACTIVE
: This state represents the end of a pagination request.
LOADING
: Represent the first Loading of the screen.
PAGINATING
: It is set when the pagination is loading.
PAGINATION_EXHAUST
: To represent the end of the pagination.
EMPTY
: This state is to set an Empty screen when the first loading result is empty.
Next, we gonna add some properties to handle the notes list and the paging state.
private val _notesList =
MutableStateFlow<MutableList<Note>>(mutableListOf())
val notesList: StateFlow<List<Note>>
get() = _notesList.asStateFlow()
The code defines two properties: _notesList
and notesList
. The first one is a MutableStateFlow of type MutableList<Note>
, which means it can hold and update a mutable list of notes. The second one is a StateFlow of type List<Note>
, which means it can only read the list of notes. The second property is initialized by calling the asStateFlow()
extension function on the first property, which creates a read-only view of the mutable state flow2.
The purpose of this code is to expose a public read-only state flow (notesList
) from a private mutable state flow (_notesList
). This way, the class that owns these properties can control how the state is updated, while other classes can only observe the state changes.
The same impl is for the pagingState property.
private val _pagingState =
MutableStateFlow<PaginationState>(PaginationState.LOADING)
val pagingState: StateFlow<PaginationState>
get() = _pagingState.asStateFlow()
We will need two more properties, one to “store” the current page and the other as a flag to paginate.
private var page = 0
var canPaginate by mutableStateOf(false)
Checkpoint!!! This is how our ViewModel class looks so far.
@HiltViewModel
class PagingRecordsViewModel @Inject constructor(
private val notesRepositoryImpl: NotesRepository,
private val dispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _notesList =
MutableStateFlow<MutableList<Note>>(mutableListOf())
val notesList: StateFlow<List<Note>>
get() = _notesList.asStateFlow()
private val _pagingState =
MutableStateFlow<PaginationState>(PaginationState.LOADING)
val pagingState: StateFlow<PaginationState>
get() = _pagingState.asStateFlow()
private var page = 0
var canPaginate by mutableStateOf(false)
}
The next step is to create a function to fetch the data, I will call it getNote.
fun getNotes(subjectID: Int) {
}
Inside getNote, first of all, we are going to set the pagingState, at this point, it could be Loading or paginating.
if (page == 0 || (canPaginate) && _pagingState.value == PaginationState.REQUEST_INACTIVE) {
_pagingState.update { if (page == 0) PaginationState.LOADING else PaginationState.PAGINATING }
}
This piece of code is a conditional statement that checks if the following conditions are met:
- The page variable is equal to 0, which means the first page of data is requested, or
- The page variable is not equal to 0, which means a subsequent page of data is requested, and the canPaginate variable is true, which means there are more pages available, and
- The _pagingState property, which is a MutableStateFlow of type PaginationState, has a value of PaginationState.REQUEST_INACTIVE, which means there is no ongoing pagination request.
If all these conditions are true, then the code updates the _pagingState property by assigning a new value to it. The new value depends on the page variable:
- If the page variable is equal to 0, then the new value is PaginationState.LOADING, which means the first page of data is being loaded.
- If the page variable is not equal to 0, then the new value is PaginationState.PAGINATING, which means a subsequent page of data is being loaded.
The purpose of this code is to control the pagination process and avoid making unnecessary or duplicate requests for data. It also updates the state of the pagination process so that other classes can observe it and react accordingly.
Next, let’s make the request to our DB, this request is async, which means we will execute it inside a coroutine and a try—catch sentence.
viewModelScope.launch(dispatcher) {
try {
val result = notesRepositoryImpl.getPagingNotesByDate(subjectID, 5, page * 5)
} catch (e: Exception) {
_pagingState.update { if (page == 0) PaginationState.ERROR else PaginationState.PAGINATION_EXHAUST }
}
}
The code launches a coroutine using the dispatcher parameter, which specifies which thread or thread pool the coroutine should run on. In this case, the dispatcher should beDispatchers.IO
, which means the coroutine will run on a thread that is optimized for Input-Output (IO) operations, like network requests or database queries.
The code then tries to execute a function called notesRepositoryImpl.getPagingNotesByDate
, which returns a list of notes from a repository based on some parameters. The number 5 represents the page size of the Query and the last parameter is the OFFSET(explained in the first part of this article).
This next piece of code is executed after the function notesRepositoryImpl.getPagingNotesByDate
.
canPaginate = result.size == 5
if (page == 0) {
if (result.isEmpty()) {
_pagingState.update { PaginationState.EMPTY }
return@launch
}
_notesList.value.clear()
_notesList.value.addAll(result)
} else {
_notesList.value.addAll(result)
}
_pagingState.update { PaginationState.REQUEST_INACTIVE }
if (canPaginate) {
page++
}
if (!canPaginate) {
_pagingState.update { PaginationState.PAGINATION_EXHAUST }
}
It performs the following steps:
- It assigns a boolean value to a variable called
canPaginate
, which indicates whether there are more pages of data available. The value is true if the result list has 5 items, which is the maximum number of items per page, and false otherwise. - It checks if the page parameter is equal to 0, which means the first page of data was requested. If so, it performs the following sub-steps:
- It checks if the result list is empty, which means there are no notes for the given subject ID. If so, it updates the_pagingState
property to PaginationState.EMPTY and returns from the coroutine.
- It clears the_notesList
property, which is a MutableStateFlow of type MutableList<Note>, and adds all the items from the result list to it. This way, it replaces the previous list of notes with the new one. - It checks if the page parameter is not equal to 0, which means a subsequent page of data was requested. If so, it adds all the items from the result list to the
_notesList
property without clearing it. This way, it appends the new list of notes to the existing one. - It updates the
_pagingState
property to PaginationState.REQUEST_INACTIVE, which means there is no ongoing pagination request. - It checks if
canPaginate
is true, which means there are more pages of data available. If so, it increments the page variable by one, which means it prepares to request the next page of data. - It checks if
canPaginate
is false, which means there are no more pages of data available. If so, it updates the_pagingState
property to PaginationState.PAGINATION_EXHAUST, which means there is no more data to load.
The purpose of this code is to process the result of the IO operation and update the state and content of the pagination process accordingly. It also keeps track of the current page and whether there are more pages to request.
The last function of our ViewModel is to clear the previous state and content of the pagination process and start over from the first page.
fun clearPaging() {
page = 0
_pagingState.update { PaginationState.LOADING }
canPaginate = false
}
Note: we can refactor the ViewModel, setting some const to our page size and the initial page.
companion object {
const val PAGE_SIZE = 5
const val INITIAL_PAGE = 0
}
Code snippet of the ViewModel.
@HiltViewModel
class PagingRecordsViewModel @Inject constructor(
private val notesRepositoryImpl: NotesRepository,
private val dispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _notesList =
MutableStateFlow<MutableList<Note>>(mutableListOf())
val notesList: StateFlow<List<Note>>
get() = _notesList.asStateFlow()
private val _pagingState =
MutableStateFlow<PaginationState>(PaginationState.LOADING)
val pagingState: StateFlow<PaginationState>
get() = _pagingState.asStateFlow()
private var page = INITIAL_PAGE
var canPaginate by mutableStateOf(false)
fun getNotes(subjectID: Int) {
if (page == INITIAL_PAGE || (page != INITIAL_PAGE && canPaginate) && _pagingState.value == PaginationState.REQUEST_INACTIVE) {
_pagingState.update { if (page == INITIAL_PAGE) PaginationState.LOADING else PaginationState.PAGINATING }
}
viewModelScope.launch(dispatcher) {
try {
val result = notesRepositoryImpl.getPagingNotesByDate(subjectID, PAGE_SIZE, page * PAGE_SIZE)
canPaginate = result.size == PAGE_SIZE
if (page == INITIAL_PAGE) {
if (result.isEmpty()) {
_pagingState.update { PaginationState.EMPTY }
return@launch
}
_notesList.value.clear()
_notesList.value.addAll(result)
} else {
_notesList.value.addAll(result)
}
_pagingState.update { PaginationState.REQUEST_INACTIVE }
if (canPaginate) {
page++
}
if (!canPaginate) {
_pagingState.update { PaginationState.PAGINATION_EXHAUST }
}
} catch (e: Exception) {
_pagingState.update { if (page == INITIAL_PAGE) PaginationState.ERROR else PaginationState.PAGINATION_EXHAUST }
}
}
}
fun clearPaging() {
page = INITIAL_PAGE
_pagingState.update { PaginationState.LOADING }
canPaginate = false
}
companion object {
const val PAGE_SIZE = 5
const val INITIAL_PAGE = 0
}
}
This is all for the ViewModel, let’s go to the compose view.
View
First at all, we are going to inject the ViewModel using Hilt.
pagingRecordsViewModel: PagingRecordsViewModel = hiltViewModel()
Then we have to create some val state to manage the LazyColum state and the StateFlows from the ViewModel.
val lazyColumnListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val noteList = pagingRecordsViewModel.notesList.collectAsStateWithLifecycle()
val pagingState = pagingRecordsViewModel.pagingState.collectAsStateWithLifecycle()
The rememberLazyListState function is a helper function that creates and remembers a LazyListState object across recompositions, so that the scroll position and other state information are preserved.
The collectAsStateWithLifecycle function is an extension function that collects the flow as a State<T> object that can be observed by the composable function, and also manages the lifecycle of the flow, so that it is only collected when the composable function is active.
Here we are making the first request to the DB and it only happens when the composable is compose the first time.
LaunchedEffect(key1 = Unit) {
pagingRecordsViewModel.clearPaging()
pagingRecordsViewModel.getNotes(subjectId)
}
The LaunchedEffect function is useful when you want to perform some actions that are tied to the lifecycle of the composable, such as fetching data, showing a snackbar, or navigating to another screen.
Next, let’s add some logic to know when to paginate.
val shouldPaginate = remember {
derivedStateOf {
pagingRecordsViewModel.canPaginate && (
lazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -5
) >= (lazyColumnListState.layoutInfo.totalItemsCount - 3)
}
}
LaunchedEffect(key1 = shouldPaginate.value) {
if (shouldPaginate.value && pagingState.value == PaginationState.REQUEST_INACTIVE) {
pagingRecordsViewModel.getNotes(subjectId)
}
}
The value is calculated by using the derivedStateOf function, which is a way to create a state object that depends on other state objects. In this case, the value depends on three state objects: .canPaginate,lazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index, and lazyColumnListState.layoutInfo.totalItemsCount. The value is true if the app can paginate and the last visible item index is close to the total items count, meaning that the user has scrolled near the end of the list. The value is false otherwise. The derivedStateOf function ensures that the value only changes when the conditions change, and not when the state objects change for other reasons. The derivedStateOf helps us to improve the performance of our composable.
Then we have our LazyColumn, this is where we are going to show our Item list.
LazyColumn(
state = lazyColumnListState,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(paddingValues),
) {
items(
noteList.value.size,
key = { noteList.value[it].noteId },
) {
RecordItem(
noteList.value[it].imgUrl.decodeUri(),
recordDescription = noteList.value[it].noteDescription,
onRecordClick = { onRecordDetail(noteList.value[it].noteId) },
onDeleteRecord = {
},
)
}
Do not forget to add lazyColumnListState to the state of the LazyColumn. Finally, we can add a when expression to listen to our pagingState.value and add new Items according to the state, like we do with the paging3 library.
when (pagingState.value) {
PaginationState.REQUEST_INACTIVE -> {
}
PaginationState.LOADING -> {
item {
BYLoadingIndicator()
}
}
PaginationState.PAGINATING -> {
item {
BYLoadingIndicator()
}
}
PaginationState.ERROR -> TODO()
PaginationState.PAGINATION_EXHAUST -> {
item {
Column(
modifier = Modifier.fillMaxHeight().padding(8.dp),
) {
Text(text = stringResource(id = R.string.record_list_end_text))
}
}
}
PaginationState.EMPTY -> {
item {
EmptyScreen(
onAddItemClick = { onAddNewRecord.invoke() },
rationaleText = R.string.records_list_no_recordsFount_text,
)
}
}
}
The result :)
Note: It is probable that you could not saw the loading indicator, that is because the data is loaded fast from the DB, to see the loading indicator for design purpose, use a delay in the repository.
Last Notes
This is my first article here in Medium, I hope the article was enjoyable, useful and informative for you. You can reach me on LinkedInd and YouTube.