Photo by Diego PH on Unsplash

M2 to M3 journey: Swipe Refresh

Javad Jafari

--

Hey, fellow Android devs! I’m Javad Jafari, an Android engineer with 5 years in the game. Today, I want to share a quick dive into an issue I stumbled upon — migrating Swipe Refresh in our fully Jetpack Compose app from Material Design 2 to the sleek Material Design 3. It might not be the highlight of my work, but it’s a puzzle worth solving, and I don’t want you to spend as much time on it as I did.

In this article, we’re keeping it focused — we’re talking about the Swipe Refresh Layout API. If you’re knee-deep in Android development and want to streamline this particular upgrade, you’re in the right place.

There are a few names for this component in Android; in the XML world, we called it SwipeRefreshLayout, then in Compose, they renamed it to PullRefresh, and now in M3, they renamed it to PullToRefresh. In this article, I call it the original name.

And by the way, if you’re curious about my Android journey, for the past two years, I’ve been contributing my skills as an Android engineer at the Part Software Group, where we’ve been developing some cool apps, including the iCup app. Fully written in Compose.

So, I recently decided to level up my app’s design system, embracing the new and shiny Material 3 (or Material You). With the decision made, I dove into the migration work. Most parts were smooth sailing, and let’s be honest, what’s software engineering without a few challenges, right?😜. However, some challenges are less enjoyable, and migrating the Swipe Refresh Layout was one of them.

For a while, there was no API for Swipe Refresh in the Material 3 design system. Faced with this, we had two options on our plate:

  1. Create our Own Swipe Refresh API:

This idea got dismissed quickly. Why? Well, let’s be practical. Why waste time crafting something likely to show up in the official Material 3 eventually?

2. Mix and Match:

We opted for the second route — a mix-and-match approach. We decided to use all the shiny new Material 3 APIs except for the Swipe Refresh Layout. However, a small hiccup appeared on the horizon: we needed to be cautious about the imports of the MaterialTheme because both the Material Design 2 and Material Design 3 dependencies were in play.

Screenshot demonstrating the presence of both M2 and M3 dependencies for import in the project.
annoyed Gif

Pretty annoying, right? We were eager to fully remove Material Design 2 as soon as possible, but there was no news about the Swipe Refresh Layout in Material Design 3. We felt a bit devastated until I spotted a single source of light — a very tiny light bulb in the darkness. I was checking out the new changes to the Material 3 Compose artifact, and that’s when I saw this.

The changelog of the Compose material 3 with pull to refresh API

And then, the search began. I scoured the Jetpack Compose documentation to check how the new API was working, but to my surprise, there was nothing there. I extended my search to the official Material 3 documentation, and still, nothing. It felt weird; why was there no information about this API anywhere? At that time, no StackOverflow answer pointed out the new API, and there wasn’t any article addressing the issue. That’s when I decided to write this article. I had to resort to checking the Android Open Source Project (AOSP) to understand how the new API was working.

The M2 API

The M2 way was pretty straightforward.

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.Text
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun M2Sample() {
val refreshScope = rememberCoroutineScope()
var refreshing by remember { mutableStateOf(false) }
var itemCount by remember { mutableIntStateOf(15) }

fun refresh() = refreshScope.launch {
refreshing = true
delay(1500)
itemCount += 5
refreshing = false
}

val state = rememberPullRefreshState(refreshing, ::refresh)

Box(Modifier.pullRefresh(state)) {
LazyColumn(Modifier.fillMaxSize()) {
if (!refreshing) {
items(itemCount) {
ListItem { Text(text = "Item ${itemCount - it}") }
}
}
}

PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter))
}
}

you can find the full sample here.

You should hoist a refreshing state and a lambda for what you want to invoke when the user pulls to refresh, and an indicator. That’s good, nothing bad about it; you have control over the refreshing value.

The M3 Api

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import kotlinx.coroutines.delay

@Composable
fun M3Sample() {
var itemCount by remember { mutableIntStateOf(15) }
val state = rememberPullToRefreshState()
if (state.isRefreshing) {
LaunchedEffect(true) {
// fetch something
delay(1500)
itemCount += 5
state.endRefresh()
}
}
Box(Modifier.nestedScroll(state.nestedScrollConnection)) {
LazyColumn(Modifier.fillMaxSize()) {
if (!state.isRefreshing) {
items(itemCount) {
ListItem({ Text(text = "Item ${itemCount - it}") })
}
}
}
PullToRefreshContainer(
modifier = Modifier.align(Alignment.TopCenter),
state = state,
)
}
}

you can find the full sample here.

In the M3 way, you lose the ability to hoist the loading or refreshing state, and there is no lambda for calling the appropriate function on pulling. Technically, it is designed to call a suspend function in the LaunchedEffect, and the loading will show until your function is suspended. Finally, you should call the endRefresh() function. I don't know the reason for the new changes, but with these updates, I can't call a function that is not a suspend function.

But Why? Gif

There is a workaround that I did to use the new API with non-suspending functions, but there is a problem with that.

@Composable
fun BoxWithSwipeRefresh(
onSwipe: () -> Unit,
isRefreshing: Boolean,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit
) {
val state = rememberPullToRefreshState()

if (state.isRefreshing) {
LaunchedEffect(true) {
onSwipe()
}
}

if (!isRefreshing) {
LaunchedEffect(true) {
state.endRefresh()
}
}

Box(modifier = modifier.nestedScroll(state.nestedScrollConnection)) {
content()
PullToRefreshContainer(
modifier = Modifier.align(Alignment.TopCenter),
state = state,
)
}
}

This way, you kind of have a duplicate state for refreshing, and you should sync them. Besides that, when refreshing stops, the indicator will disappear right away; it will not animate like the M2 way. I searched for an alternative way but failed. If you find any, let me know.

Gif of what is wrong with the workaround

The new API is not what we expected, but we decided to treat this as a technical debt and use the new API anyway.

The new API of the Swipe Refresh is still experimental, and we hope the developers consider the issues we’ve encountered and address them in future updates. It served as a valuable lesson for us: avoid using new APIs if they are still experimental or incomplete. The challenges we faced highlighted the importance of stability and completeness in API design. As we navigate the ever-evolving landscape of Android development, it’s crucial to weigh the benefits against potential pitfalls, especially when adopting features that are still in the experimental stage.

If you’re interested in checking out the code of the Backgroundable project, an open-source application for finding the best wallpaper for your phone, you can find it on GitHub.

--

--

Javad Jafari
Javad Jafari

Written by Javad Jafari

Android developer at Part Software Group

Responses (3)