Adding pull-to-refresh to your LazyList with Jetpack Compose is a breeze. Seriously, a few lines.. However, the default look and feel is not that satisfying. We wanted to do it a bit nicer, similar to iOS version: move the list down as user pulls it down and give feedback to the user that the list is about to refresh if they keep pulling and tell what was the last time it was refreshed. We also wanted to increase the default threshold for refresh, because we were accidentally refreshing the page while scrolling up.
Luckily it was pretty easy to achieve this with Compose. In this article, I’ll try to show how to build a simplified demo app like this:
I prepared a simple sample for demonstration purposes. I will try to explain it step by step below, but if you want to directly jump to the final code, here is the link to the sample.
So the default basic implementation of pull-to-refresh with Compose is something like below: We have a pullRefreshState that we pass as a modifier to the container, and a PullRefreshIndicator synchronised with that.
First let’s increase the default threshold, as it is pretty easy to do with the library. rememberPullRefreshState has an argument called refreshThreshold:
Now user needs to pull this down a bit more to be able to refresh. This will fix the undesired refreshes during scrolling up.
We won’t use the default PullRefreshIndicator that the library provides, we can remove that. Instead we will show an indicator on top of the list, which will push the content down as user pulls it. If you think about it, this can be a simple composable on top of the list that extends its height as you pull the screen down.
The indicator has 0 height by default. As the user pulls the screen, we will increase the height of this composable in parallel, which will push the list down. To observe how much user pulled the screen, we can use:
This is a float in percentage, starting from 0 at default position, reaching to 1 when threshold is reached and can also go beyond that. If you want to translate this into a height, it is enough to multiply it by 100:
This is enough to push the list down as the user pulls it. You can put whatever you like in the place of that indicator. But here is the behavior we will be building in this sample:
- When user first pulls, it says “pull to refresh” and it also shows the last time it was refreshed..
- When user reaches the threshold, it says “release to refresh”
- During refreshing it says “refreshing” and shows a loading icon.
- When refreshing is complete, indicator disappears and list returns to the original position.
To easily distinguish these states, we used an enum:
With all of that together, our pull-to-refresh indicator looks like this:
So when user is pulling, we apply a dynamic height in parallel. I preferred to limit it to a maximum of 100 dp’s. You can adapt it to your taste. Notice also animateContentSize() modifier, which provides a smooth transition between these states.
timeElapsed is the time passed since the last time the screen was refreshed. You can keep track of the refresh time, calculate the time between now and the last refresh time and convert it to an appropriate text. I won’t go into the details of that in this article, but you can see an example implementation in the sample.
indicatorState is one of the four states mentioned above: Default, Pulling, ReachedThreshold, Refreshing.. If we observe that pullRefreshState.progress is more then 0, that means user is pulling down. If the progress reaches 1, that means user reached threshold. O means default state, no pull.
When user release her finger after reaching threshold, it will go into refresh state. The library already provides a callback for that. We can use the onRefresh callback of the library to update our indicator state to Refreshing.
Last piece to make this properly work is to change back indicator state to Default when refreshing is complete. Otherwise it will show refreshing forever. Where to do this? It depends on your case. In this sample, I have done it the viewmodel, when result arrives.
(Both in sucess and error cases) But in our real app, where we use compose paging, it is done inside the main composable.
You can keep relevant states and functions for pull-to-refresh, like refreshIndicatorState or lastRefreshTime directly in your viewmodel. However, in my case, this was pretty verbose and as I had to implement the same thing in many screens, I prefered to create a reusable composable and wrap relevant data in a stateholder class.
Here is our reusable PullToRefreshLayout:
And here is the state holder we use for this layout:
With all of that, when you want to add pull-to-refresh to a screen, it looks like this:
Extras- Animated new Items
If there are new items when the list is refreshed, you can make them animate properly with simply adding the modifier .animateItemPlacement() to your LazyLayout. You should also provide appropriate ids to your items for this to work properly.
Extras- What if you have an UiState?
What if you also have a uiState with loading, success and error states on the same screen? Where does refreshing go in this picture? You might be tempted to map refreshing state to the UiState.Loading , but you probably don’t want to show the same refresh indicator on top during initial load..
What if you add Refresh as a new UiState? Or a subtype of loading? You might manage to make it work, but note that if you are switching your composables between these states and showing your list only on the success case, then your list will disappear during refreshing. That was not what we wanted in this case. We wanted the list to stay there and move down. That’s why I preferred to keep refresh states separately then the ui state we had. I still used a variable to differentiate initial loading and refresh cases though. That was because the data layer was emitting a loading state at the start of fetching, and I didn’t want to map it to the UiState.Loading (for the list to stay there)
Extras- With Compose Paging
What if you also have compose paging on the same screen? That was our case. So you probably have a paging flow similar to this in your viewmodel:
This is a flow, saved as a variable. How to refresh this? I first thought of making this a function that returns the pager flow, and to call it again when refreshed, but then I was losing the cachedIn(viewModelScope) part, which is important for saving paging state and scroll position.
The solution I found was mapping this flow from another variable that changes when I want to refresh:
So when user releases to refresh, we update the lastRefreshTime and this paging flow is retriggered. (You can use another variable, if your implementation doesn’t care about last refresh time)
I hope it helps. Thanks for reading and happy coding!