Blog Infos
Author
Published
Topics
Author
Published

 

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.

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 ViewModelViewModel 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 PaginationExhaustViewModel 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

Job Offers


    Senior Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

    Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

Jobs

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 viewModelYou 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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
This tutorial teaches us how to display the most popular YouTube video content in…
READ MORE
blog
RecyclerView is a really cool and powerful tool to display list(s) of content on Android.…
READ MORE
blog
Hi everyone! We (Kaspresso Team and AvitoTech) are back with more about automated Android testing. Previously…
READ MORE
blog
Ever wondered how to implement a synchronizer between Android’s RecyclerView and TabLayout? What are…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu