Jetpack Compose PullRefresh

If you've ever pulled down on a list in a mobile app to fetch new content, you've used a pull to refresh gesture. Jetpack Compose PullRefresh brings this exact interaction to your Compose UI with just a modifier and two composables. Jetpack Compose PullRefresh lets users drag content downward to trigger a refresh, and it ships with a built-in, animated indicator so you don't have to draw the arrow yourself.

Jetpack Compose PullRefresh lives in the material package under the androidx.compose.material.pullrefresh namespace, and it works with any scrollable composable, from a LazyColumn to a plain Column with vertical scroll. PullRefresh handles the drag distance and the animation timing for you, so you never write easing curves by hand. By the end of this guide, you'll know exactly how PullRefresh, rememberPullRefreshState, and PullRefreshIndicator fit together to build a working pull to refresh compose screen.

Getting Started with Jetpack Compose PullRefresh

Before you can use PullRefresh, you need the Jetpack Compose material dependency in your project, since the Compose Material PullRefresh package ships as part of that artifact rather than as a separate library. PullRefresh is still marked experimental in Jetpack Compose, so every composable and function tied to it needs an opt-in annotation.

The three PullRefresh building blocks you'll use together are rememberPullRefreshState to hold drag progress and refresh state, Modifier.pullRefresh to attach the drag gesture to a container, and PullRefreshIndicator to draw the spinning arrow. None of these three pieces do anything useful alone — Jetpack Compose PullRefresh only works when state, modifier, and indicator are wired to the same refreshing value. See the official Jetpack Compose documentation for the full material package overview.

Think of Jetpack Compose PullRefresh as a small pipeline. A boolean flag says whether a refresh is happening, rememberPullRefreshState turns that flag into a state object, Modifier.pullRefresh reads the state to detect the drag, and PullRefreshIndicator reads the same state to animate. Every Jetpack Compose PullRefresh screen you build will follow this same pipeline, no matter what the actual refresh work looks like underneath.

Creating State with rememberPullRefreshState

The rememberPullRefreshState function is the entry point for every Jetpack Compose PullRefreshState you'll build. It takes a refreshing boolean, an onRefresh lambda, and two optional Dp values for positioning, and it returns a state object that both the pullRefresh modifier and the PullRefreshIndicator read from.

PullRefresh reads all four of these together, so here's what each parameter controls:

  • refreshing: a Boolean that tells PullRefresh whether a refresh operation is currently in progress, so the indicator knows whether to keep spinning or settle back
  • onRefresh: the lambda PullRefresh calls once the user has dragged past the threshold and released
  • refreshThreshold: an optional Dp distance the user must drag before releasing triggers onRefresh
  • refreshingOffset: an optional Dp value describing how far below the top edge the indicator settles while refreshing is true
kotlin
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun RememberStateExample() {
    var refreshing by remember { mutableStateOf(false) }

    val state = rememberPullRefreshState(
        refreshing = refreshing,
        onRefresh = { refreshing = true }
    )
}

Notice that the Jetpack Compose PullRefreshState object returned here isn't something you construct directly with a constructor. Instead, rememberPullRefreshState remembers it across recompositions, the same way remember does for any other piece of Jetpack Compose state, so the drag progress survives recomposition without resetting to zero on every frame.

Applying the pullRefresh Modifier to Scrollable Content

Once you have a PullRefreshState, the next step is attaching it to something scrollable in Jetpack Compose using Modifier.pullRefresh. This modifier is what actually intercepts the vertical drag before it reaches the content underneath, calculating how far the user has pulled and feeding that progress back into the state object.

Modifier.pullRefresh takes two arguments: the state object from rememberPullRefreshState, and an optional enabled flag. Applying pull to refresh Compose behavior to a screen is as simple as chaining this modifier onto a Box, Column, or any layout that wraps your scrollable list. This is the core gesture-detection piece of Jetpack Compose PullRefresh, and it needs to wrap only the container your users will actually drag on. PullRefresh distinguishes an intentional downward pull from ordinary list scrolling using this same modifier.

kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.ExperimentalMaterialApi
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PullRefreshModifierExample() {
    var refreshing by remember { mutableStateOf(false) }
    val state = rememberPullRefreshState(refreshing, { refreshing = true })

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pullRefresh(state = state, enabled = true)
    )
}

Running this code attaches the drag gesture to the full-screen Box, so any downward swipe starting near the top of that Box now feeds progress into state instead of being ignored. Because pullRefresh is a modifier rather than a composable, PullRefresh composes cleanly with padding, background, and scrolling modifiers already on the same container.

Displaying the PullRefreshIndicator Composable

A PullRefreshIndicator element in Jetpack Compose is what actually renders the spinning arrow your users see while dragging. PullRefreshIndicator needs to sit inside the same Box that carries the pullRefresh modifier, and it needs to be aligned to the top center so it appears right where the user's finger is dragging from.

The PullRefreshIndicator composable reads the same refreshing boolean and state object you already created, so this Jetpack Compose PullRefresh indicator always stays in sync with the drag progress without any extra wiring on your part. PullRefresh never desyncs from your refreshing flag once this wiring is in place.

kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.ExperimentalMaterialApi
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun IndicatorExample() {
    var refreshing by remember { mutableStateOf(false) }
    val state = rememberPullRefreshState(refreshing, { refreshing = true })

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pullRefresh(state)
    ) {
        PullRefreshIndicator(
            refreshing = refreshing,
            state = state,
            modifier = Modifier.align(Alignment.TopCenter)
        )
    }
}

Because PullRefreshIndicator is placed as a child inside the Box rather than beside it, PullRefreshIndicator draws on top of whatever content sits underneath, which is exactly how a native pull to refresh screen behaves. Jetpack Compose handles the layering automatically as long as the indicator comes after your scrollable content in the Box's children.

Customizing PullRefreshIndicator Colors and Scale

PullRefreshIndicator accepts a handful of styling parameters beyond refreshing and state, letting you match the refresh indicator to your app's theme instead of settling for the default look. These parameters are what make a Jetpack Compose PullRefresh screen feel like part of your app instead of a generic default. PullRefresh applies all three styling parameters together, so a handful of lines is enough to fully theme the indicator.

ParameterTypeDefaultWhat it controls
backgroundColorColorsurface colorfills the circular disc behind the arrow
contentColorColorprimary colorcolors the arrow and spinner stroke
scaleBooleanfalsewhen true, the indicator grows and shrinks as the user drags instead of staying a fixed size
kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.ExperimentalMaterialApi
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun StyledIndicatorExample() {
    var refreshing by remember { mutableStateOf(false) }
    val state = rememberPullRefreshState(refreshing, { refreshing = true })

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pullRefresh(state)
    ) {
        PullRefreshIndicator(
            refreshing = refreshing,
            state = state,
            modifier = Modifier.align(Alignment.TopCenter),
            backgroundColor = Color.White,
            contentColor = Color.Magenta,
            scale = true
        )
    }
}

Setting scale to true here makes the indicator visibly grow as the user pulls further down, which gives extra feedback that dragging further actually does something before the threshold is reached. Swapping backgroundColor and contentColor is the quickest way to make a PullRefreshIndicator element in Jetpack Compose match a branded color palette instead of the Material defaults.

Setting the Refresh Threshold and Offset

Two more parameters on rememberPullRefreshState control the physical feel of the Jetpack Compose PullRefresh drag gesture: refreshThreshold and refreshingOffset. Both are Dp values, and both have sensible defaults, but tuning them changes how far the user has to pull before a refresh actually fires. PullRefresh clamps the drag distance internally, so values outside a sane range are simply ignored rather than crashing the gesture.

  • refreshThreshold sets the drag distance required before releasing triggers onRefresh; a larger value means the user has to pull further before anything happens
  • refreshingOffset sets where the indicator rests while refreshing stays true, measured from the top of the container
  • Both values accept any Dp, so you can tighten or loosen the gesture to match how far your layout's top app bar or padding pushes content down
kotlin
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ThresholdExample() {
    var refreshing by remember { mutableStateOf(false) }

    val state = rememberPullRefreshState(
        refreshing = refreshing,
        onRefresh = { refreshing = true },
        refreshThreshold = 80.dp,
        refreshingOffset = 64.dp
    )
}

Raising refreshThreshold to 80.dp here means the drag needs to travel further than the 64.dp default before the release counts as a real refresh request, which is useful if your screen already has a lot of vertical scroll momentum and you want PullRefresh to avoid accidental triggers.

Building a Practical Pull to Refresh List in Jetpack Compose

A realistic android pull to refresh compose screen combines everything covered so far about Jetpack Compose PullRefresh with a LazyColumn and some kind of asynchronous fetch, usually a network or database call. The pattern is always the same: set refreshing to true, launch the fetch in a coroutine, update your list, then set refreshing back to false.

This next example simulates a realistic Jetpack Compose PullRefresh workflow: fetching a list of articles from a repository. The refreshing boolean drives both the PullRefreshIndicator and a coroutine launched with rememberCoroutineScope, so the indicator disappears automatically once the simulated network call finishes. PullRefresh doesn't care what the coroutine actually fetches, only that refreshing eventually flips back to false.

kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
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.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 androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ArticleListWithPullRefresh() {
    var refreshing by remember { mutableStateOf(false) }
    var articles by remember { mutableStateOf(listOf("Article 1", "Article 2", "Article 3")) }
    val scope = rememberCoroutineScope()

    val state = rememberPullRefreshState(refreshing, {
        refreshing = true
        scope.launch {
            delay(1500)
            articles = articles + "Article ${articles.size + 1}"
            refreshing = false
        }
    })

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pullRefresh(state)
    ) {
        LazyColumn(modifier = Modifier.padding(16.dp)) {
            items(articles) { article ->
                Text(text = article, modifier = Modifier.padding(vertical = 8.dp))
            }
        }
        PullRefreshIndicator(
            refreshing = refreshing,
            state = state,
            modifier = Modifier.align(Alignment.TopCenter)
        )
    }
}

Walking through what happens when this runs: dragging down on the LazyColumn moves the indicator through the pullRefresh modifier's progress calculation, releasing past the threshold sets refreshing to true and starts a coroutine, the coroutine waits to simulate a real fetch, and once it appends a new article and flips refreshing back to false, PullRefreshIndicator animates away. This loading state cycle is exactly what a production android pull to refresh compose screen looks like, just with a real repository call in place of the delay.

Other PullRefresh Modifier Options

PullRefresh ships with a few additional knobs beyond the ones already covered, and a few smaller details round out the Jetpack Compose Material PullRefresh package that don't need their own full section but are still worth knowing.

  • enabled: the second parameter on Modifier.pullRefresh lets you disable dragging entirely, which is handy when refreshing is already true and you don't want overlapping requests
  • ExperimentalMaterialApi opt-in: every function tied to PullRefresh — rememberPullRefreshState, the modifier, and PullRefreshIndicator — requires the same opt-in annotation, so it's common to annotate the whole containing composable once instead of each call individually
  • Nested scrolling: PullRefresh is built on Compose's nested scroll system, which is why it cooperates correctly with a LazyColumn's own scrolling instead of fighting it for drag events
kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.ExperimentalMaterialApi
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun DisabledPullRefreshExample(isEditing: Boolean) {
    var refreshing by remember { mutableStateOf(false) }
    val state = rememberPullRefreshState(refreshing, { refreshing = true })

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pullRefresh(state = state, enabled = !isEditing)
    )
}

Setting enabled to !isEditing here means the Jetpack Compose PullRefresh gesture switches off automatically whenever the screen enters an editing mode, which prevents a stray downward drag from firing a refresh while the user is in the middle of something else.

Complete Jetpack Compose PullRefresh Example

Here's a full, self-contained Jetpack Compose PullRefresh example that ties rememberPullRefreshState, the pullRefresh modifier, and PullRefreshIndicator together into one working screen you can drop straight into a project. For the full parameter reference, check the Compose Material pullrefresh package documentation.

kotlin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
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.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 androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface {
                    PullRefreshScreen()
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PullRefreshScreen() {
    var refreshing by remember { mutableStateOf(false) }
    var items by remember { mutableStateOf((1..5).map { "Item $it" }) }
    val scope = rememberCoroutineScope()

    val pullRefreshState = rememberPullRefreshState(
        refreshing = refreshing,
        onRefresh = {
            refreshing = true
            scope.launch {
                delay(1200)
                items = listOf("Item ${items.size + 1}") + items
                refreshing = false
            }
        },
        refreshThreshold = 72.dp,
        refreshingOffset = 56.dp
    )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pullRefresh(state = pullRefreshState, enabled = true)
    ) {
        LazyColumn(modifier = Modifier.padding(16.dp)) {
            items(items) { entry ->
                Text(text = entry, modifier = Modifier.padding(vertical = 8.dp))
            }
        }
        PullRefreshIndicator(
            refreshing = refreshing,
            state = pullRefreshState,
            modifier = Modifier.align(Alignment.TopCenter),
            backgroundColor = Color.White,
            contentColor = Color.Blue,
            scale = true
        )
    }
}

This complete example wires up every piece covered in this guide: PullRefreshScreen holds the refreshing boolean and the list of items, rememberPullRefreshState turns those into a pullRefreshState object with a custom threshold and offset, the pullRefresh modifier attaches drag detection to the Box, and PullRefreshIndicator renders on top with custom colors and scaling enabled. PullRefresh, rememberPullRefreshState, and PullRefreshIndicator together are the whole surface area of this API — there's nothing else PullRefresh needs to function. Copy this Jetpack Compose PullRefresh example into a Jetpack Compose project with the material dependency and it runs as-is.