This will be Part 2 of Everything You Need to Know About RecyclerView.
Everything You Need to Know About RecyclerView
In this article, we’ll learn how to use savedStateHandle
for Process Death and Orientation Change in RecyclerView.
Getting Started
Process Death
As the system runs low on memory, it kills processes in the cache beginning with the process least recently used. When the user navigates back to the app, the system will restart the app in a new process.
Since this only happens if the user has not interacted with the app for a while, it might be permissible to have them return to the app and find it in the initial state.
I recommend you to check these article and video to understand it better.
- Incorporate Lifecycle-Aware Components | Android Developers
- Learn Android Process Death in 6min — YouTube
Orientation Change
Some device configurations can change during runtime, such as screen orientation. When such a change occurs, Android restarts the running
Activity The restart behavior is designed to help your application adapt to new configurations by automatically reloading your application with alternative resources that match the new device configuration.
To handle orientation change properly, we need to restore its previous state. For that, we’ll take advantage of onSaveInstanceState() and
ViewModel
.
First of all, we cannot save large sets of data into our system as the documentation states.
… it is not designed to carry large objects (such as bitmaps) and the data within it must be serialized then deserialized on the main thread, which can consume a lot of memory and make the configuration change slow.
So, what we’ll do is, we’ll take advantage of ViewModel
. ViewModel
objects are preserved across configuration changes, so they are the perfect place to keep UI data without having to query them again.
Minor Code Changes
We’ll work on our previous code base from Everything You Need to Know About RecyclerView. Before we do any implementation, we’ll need to make some changes.
sealed class NetworkResponse<out T> { data class Loading( val isPaginating: Boolean = false, ): NetworkResponse<Nothing>() data class Success<out T>( val data: T, val isPaginationData: Boolean = false, val isPaginationExhausted: Boolean = false, ): NetworkResponse<T>() data class Failure( val errorMessage: String, ): NetworkResponse<Nothing>() }
In NetworkResponse
, we’ll move handling PaginationExhaust
task to Success
because previously, in case of orientation change on PaginationExhaust
, ViewModel
was keeping the state value as Failure
which causes empty list after the orientation change.
We’ll also need to make small adjustments in other classes.
class MainRepository { //... fun fetchData(page: Int): Flow<NetworkResponse<ArrayList<RecyclerViewModel>>> = flow { emit(NetworkResponse.Loading(page != 1)) kotlinx.coroutines.delay(2000L) try { if (page == 1) emit(NetworkResponse.Success(tempList.toList() as ArrayList<RecyclerViewModel>)) else { val tempPaginationList = arrayListOf<RecyclerViewModel>().apply { for (i in 0..PAGE_SIZE) { add(RecyclerViewModel(UUID.randomUUID().toString(), "Content ${i * 2}"),) } } if (page < 4) { emit(NetworkResponse.Success( tempPaginationList, isPaginationData = true, )) } else { // CHANGE emit(NetworkResponse.Success( arrayListOf(), isPaginationData = true, isPaginationExhausted = true )) } } } catch (e: Exception) { // CHANGE if (page != 1) { emit(NetworkResponse.Success( arrayListOf(), isPaginationData = true, isPaginationExhausted = true )) } else { emit(NetworkResponse.Failure( e.message ?: e.toString(), )) } } }.flowOn(Dispatchers.IO) }
We’ve changed NetworkResponse.Failure
to NetworkResponse.Success
with empty list and isPaginationExhausted = true
. We are sending empty list since we have no data left to paginate. It can be changed depending on your endpoint’s behavior.
abstract class BaseAdapter<T>(open val interaction: Interaction<T>): RecyclerView.Adapter<RecyclerView.ViewHolder>() { //... fun setErrorView(errorMessage: String) { setState(RecyclerViewEnum.Error) this.errorMessage = errorMessage notifyDataSetChanged() } fun setData( newList: ArrayList<T>, isPaginationData: Boolean = false, isPaginationExhausted: Boolean = false ) { setState(if (isPaginationExhausted) RecyclerViewEnum.PaginationExhaust else RecyclerViewEnum.View) if (!isPaginationData) { if (arrayList.isNotEmpty()) arrayList.clear() arrayList.addAll(newList) notifyDataSetChanged() } else { notifyItemRemoved(itemCount) newList.addAll(0, arrayList) handleDiffUtil(newList) } } }
Finally, in BaseAdapter
we’ll change setErrorView
and setData
functions. setErrorView
isn’t going to be responsible from PaginationExhaust
. It’ll only update if there is an error.
In setData
, we add isPaginationExhausted
parameter and use it for setState
function. And that’s it.
Process Death & Orientation Change
Now, we are ready to implement Process Death and Orientation Change.
// Key Values const val PAGE_KEY = "rv.page" const val SCROLL_POSITION_KEY = "rv.scroll_position" class MainViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { // Other Variables // ... /** Process Death Restore * isRestoringData is necessary to handle recyclerview.scrollToPosition * scrollPosition is middle item's position on recyclerview */ var isRestoringData = false private var page: Int = savedStateHandle[PAGE_KEY] ?: 1 var scrollPosition: Int = savedStateHandle[SCROLL_POSITION_KEY] ?: 0 private set // Variable for detecting orientation change var didOrientationChange = false init { if (page != 1) { restoreData() } else { fetchData() } } private fun restoreData() { isRestoringData = true var isPaginationExhausted = false val tempList = arrayListOf<RecyclerViewModel>() viewModelScope.launch(Dispatchers.IO) { for (p in 1..page) { val job = launch(Dispatchers.IO) { repository.fetchData(p).collect { state -> if (state is NetworkResponse.Success) { tempList.addAll(state.data) isPaginationExhausted = state.isPaginationExhausted } } } job.join() } withContext(Dispatchers.Main) { _rvList.value = NetworkResponse.Success(tempList, isPaginationData = true, isPaginationExhausted = isPaginationExhausted) } } } private fun setPagePosition(newPage: Int) { page = newPage savedStateHandle[PAGE_KEY] = newPage } fun setScrollPosition(newPosition: Int) { if (!isRestoringData && !didOrientationChange) { scrollPosition = newPosition savedStateHandle[SCROLL_POSITION_KEY] = newPosition } } // Other Functions // ... }
Job Offers
SavedStateHandle is a key-value map that will let you write and retrieve objects to and from the saved state. These values will persist after the process is killed by the system and remain available via the same object.
In ViewModel
, we pass savedStateHandle
in constructor.
isRestoringData
and didOrientationChange
is necessary to return to previous scroll positions.
In page
and scrollPosition
, if we’ve saved the values in savedStateHandle
we receive them otherwise we return default values.
restoreData
make requests to our “endpoint” from initial page number until the last page number that user was on before Process Death. We add all data into tempList
and wait all requests to finish and finally send it back to UI. This is not really ideal for most production scenarios since it causes user to wait a lot and makes a lot of consecutive requests. What you can do instead is to get data from caches and present it to the user which will be much more performant and user friendly.
setPagePosition
simply sets the newPage
value to page
and also saves it to savedStateHandle
.
setScrollPosition
is similar to setPagePosition
but with a condition. isRestoringData
and didOrientationChange
have to be false. Because without the condition check, in case of Process Death and Orientation Change scrollPosition
sets itself to zero.
That’s it for MainViewModel.
class MainFragment : BaseFragment<FragmentMainBinding>() { // ... override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) viewModel.didOrientationChange = true } private fun setObservers() { viewModel.rvList.observe(viewLifecycleOwner) { response -> // ... when(response) { is NetworkResponse.Failure -> { recyclerViewAdapter?.setErrorView(response.errorMessage) } is NetworkResponse.Loading -> { recyclerViewAdapter?.setLoadingView(response.isPaginating) } is NetworkResponse.Success -> { recyclerViewAdapter?.setData(response.data, response.isPaginationData, response.isPaginationExhausted) if (viewModel.isRestoringData || viewModel.didOrientationChange) { binding.mainRV.scrollToPosition(viewModel.scrollPosition - 1) if (viewModel.isRestoringData) { viewModel.isRestoringData = false } else { viewModel.didOrientationChange = false } } } } } // ... } private fun setRecyclerView() { binding.mainRV.apply { // Recyclerview Implementation // ... var isScrolling = false addOnScrollListener(object: RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) isScrolling = newState != AbsListView.OnScrollListener.SCROLL_STATE_IDLE } override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) val itemCount = linearLayoutManager.itemCount val lastVisibleItemPosition = linearLayoutManager.findLastVisibleItemPosition() // Set new position val centerScrollPosition = (linearLayoutManager.findLastCompletelyVisibleItemPosition() + linearLayoutManager.findFirstCompletelyVisibleItemPosition()) / 2 viewModel.setScrollPosition(centerScrollPosition) if (lastVisibleItemPosition > PAGE_SIZE.plus(PAGE_SIZE.div(2)) && dy <= -75) { binding.fab.show() } else if (lastVisibleItemPosition <= PAGE_SIZE.plus(PAGE_SIZE.div(2)) || dy >= 60) { binding.fab.hide() } recyclerViewAdapter?.let { if ( isScrolling && lastVisibleItemPosition >= itemCount.minus(5) && it.canPaginate && !it.isPaginating ) { viewModel.fetchData() } } } }) } } }
I’ve removed or commented unnecessary codes, please ignore them, they are not related to this article. You can check full code at the bottom of this article.
In onSaveInstanceState
, we set didOrientationChange = true
to handle Orientation Change.
Inside of setObservers()
> is NetworkResponse.Success
, we check isRestoringData
or didOrientationChange
is true and scroll to position that we keep on viewModel
. After scrolling, we set the necessary variable to false.
Finally, in setRecyclerView()
> addOnScrollListener
, we set centerScrollPosition
value and send it to viewModel
. You can test and change scroll position however you like. I found setting the scroll position to the middle completely visible item is the best.
That’s it! I hope it was useful. 👋👋
Note: If you want to test Process Death, I recommend you to follow this article.
This article was originally published on proandroiddev.com on January 12, 2023