androidx.compose.material3.pulltorefresh

Interfaces

PullToRefreshState

The state of a PullToRefreshBox which tracks the distance that the container and indicator have been pulled.

Cmn

Objects

PullToRefreshDefaults

Contains the default values for PullToRefreshBox

Cmn

Top-level functions summary

Unit
@Composable
@ExperimentalMaterial3Api
PullToRefreshBox(
    isRefreshing: Boolean,
    onRefresh: () -> Unit,
    modifier: Modifier,
    state: PullToRefreshState,
    contentAlignment: Alignment,
    indicator: @Composable BoxScope.() -> Unit,
    content: @Composable BoxScope.() -> Unit
)

PullToRefreshBox is a container that expects a scrollable layout as content and adds gesture support for manually refreshing when the user swipes downward at the beginning of the content.

Cmn
PullToRefreshState

Creates a PullToRefreshState.

Cmn
PullToRefreshState

Create and remember the default PullToRefreshState.

Cmn

Extension functions summary

Modifier
@ExperimentalMaterial3Api
Modifier.pullToRefresh(
    isRefreshing: Boolean,
    state: PullToRefreshState,
    enabled: Boolean,
    threshold: Dp,
    onRefresh: () -> Unit
)

A Modifier that adds nested scroll to a container to support a pull-to-refresh gesture.

Cmn

Top-level functions

PullToRefreshBox

@Composable
@ExperimentalMaterial3Api
fun PullToRefreshBox(
    isRefreshing: Boolean,
    onRefresh: () -> Unit,
    modifier: Modifier = Modifier,
    state: PullToRefreshState = rememberPullToRefreshState(),
    contentAlignment: Alignment = Alignment.TopStart,
    indicator: @Composable BoxScope.() -> Unit = { Indicator( modifier = Modifier.align(Alignment.TopCenter), isRefreshing = isRefreshing, state = state ) },
    content: @Composable BoxScope.() -> Unit
): Unit

PullToRefreshBox is a container that expects a scrollable layout as content and adds gesture support for manually refreshing when the user swipes downward at the beginning of the content. By default, it uses PullToRefreshDefaults.Indicator as the refresh indicator, but you may also choose to set your own indicator or use PullToRefreshDefaults.LoadingIndicator.

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.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier

var itemCount by remember { mutableIntStateOf(15) }
var isRefreshing by remember { mutableStateOf(false) }
val state = rememberPullToRefreshState()
val coroutineScope = rememberCoroutineScope()
val onRefresh: () -> Unit = {
    isRefreshing = true
    coroutineScope.launch {
        delay(5000)
        itemCount += 5
        isRefreshing = false
    }
}

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text("Title") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = onRefresh) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    PullToRefreshBox(
        modifier = Modifier.padding(it),
        state = state,
        isRefreshing = isRefreshing,
        onRefresh = onRefresh,
    ) {
        LazyColumn(Modifier.fillMaxSize()) {
            items(itemCount) { ListItem({ Text(text = "Item ${itemCount - it}") }) }
        }
    }
}

Using a androidx.compose.material3.LoadingIndicator as the PullToRefreshBox indicator can be done like this

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.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

var itemCount by remember { mutableIntStateOf(15) }
var isRefreshing by remember { mutableStateOf(false) }
val state = rememberPullToRefreshState()
val coroutineScope = rememberCoroutineScope()
val onRefresh: () -> Unit = {
    isRefreshing = true
    coroutineScope.launch {
        delay(5000)
        itemCount += 5
        isRefreshing = false
    }
}

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text("Title") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = onRefresh) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    PullToRefreshBox(
        modifier = Modifier.padding(it),
        state = state,
        isRefreshing = isRefreshing,
        onRefresh = onRefresh,
        indicator = {
            PullToRefreshDefaults.LoadingIndicator(
                state = state,
                isRefreshing = isRefreshing,
                modifier = Modifier.align(Alignment.TopCenter),
            )
        }
    ) {
        LazyColumn(Modifier.fillMaxSize()) {
            items(itemCount) { ListItem({ Text(text = "Item ${itemCount - it}") }) }
        }
    }
}

View models can be used as source as truth as shown in

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.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

val viewModel = remember {
    object : ViewModel() {
        private val refreshRequests = Channel<Unit>(1)
        var isRefreshing by mutableStateOf(false)
            private set

        var itemCount by mutableStateOf(15)
            private set

        init {
            viewModelScope.launch {
                for (r in refreshRequests) {
                    isRefreshing = true
                    try {
                        itemCount += 5
                        delay(5000) // simulate doing real work
                    } finally {
                        isRefreshing = false
                    }
                }
            }
        }

        fun refresh() {
            refreshRequests.trySend(Unit)
        }
    }
}

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text("Title") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(
                    enabled = !viewModel.isRefreshing,
                    onClick = { viewModel.refresh() }
                ) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    PullToRefreshBox(
        modifier = Modifier.padding(it),
        isRefreshing = viewModel.isRefreshing,
        onRefresh = { viewModel.refresh() }
    ) {
        LazyColumn(Modifier.fillMaxSize()) {
            if (!viewModel.isRefreshing) {
                items(viewModel.itemCount) {
                    ListItem({ Text(text = "Item ${viewModel.itemCount - it}") })
                }
            }
        }
    }
}

A custom state implementation can be initialized like this

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
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.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshState
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.ui.Modifier

var itemCount by remember { mutableIntStateOf(15) }
var isRefreshing by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val onRefresh: () -> Unit = {
    isRefreshing = true
    coroutineScope.launch {
        // fetch something
        delay(5000)
        itemCount += 5
        isRefreshing = false
    }
}

val state = remember {
    object : PullToRefreshState {
        private val anim = Animatable(0f, Float.VectorConverter)

        override val distanceFraction
            get() = anim.value

        override val isAnimating: Boolean
            get() = anim.isRunning

        override suspend fun animateToThreshold() {
            anim.animateTo(1f, spring(dampingRatio = Spring.DampingRatioHighBouncy))
        }

        override suspend fun animateToHidden() {
            anim.animateTo(0f)
        }

        override suspend fun snapTo(targetValue: Float) {
            anim.snapTo(targetValue)
        }
    }
}

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text("TopAppBar") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = onRefresh) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    PullToRefreshBox(
        modifier = Modifier.padding(it),
        isRefreshing = isRefreshing,
        onRefresh = onRefresh,
        state = state
    ) {
        LazyColumn(Modifier.fillMaxSize()) {
            if (!isRefreshing) {
                items(itemCount) { ListItem({ Text(text = "Item ${itemCount - it}") }) }
            }
        }
    }
}

Scaling behavior can be implemented like this

import androidx.compose.animation.core.LinearOutSlowInEasing
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.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer

var itemCount by remember { mutableStateOf(15) }
var isRefreshing by remember { mutableStateOf(false) }
val state = rememberPullToRefreshState()
val coroutineScope = rememberCoroutineScope()
val onRefresh: () -> Unit = {
    isRefreshing = true
    coroutineScope.launch {
        // fetch something
        delay(5000)
        itemCount += 5
        isRefreshing = false
    }
}

val scaleFraction = {
    if (isRefreshing) 1f
    else LinearOutSlowInEasing.transform(state.distanceFraction).coerceIn(0f, 1f)
}

Scaffold(
    modifier =
        Modifier.pullToRefresh(
            state = state,
            isRefreshing = isRefreshing,
            onRefresh = onRefresh
        ),
    topBar = {
        TopAppBar(
            title = { Text("TopAppBar") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = onRefresh) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    Box(Modifier.padding(it)) {
        LazyColumn(Modifier.fillMaxSize()) {
            if (!isRefreshing) {
                items(itemCount) { ListItem({ Text(text = "Item ${itemCount - it}") }) }
            }
        }
        Box(
            Modifier.align(Alignment.TopCenter).graphicsLayer {
                scaleX = scaleFraction()
                scaleY = scaleFraction()
            }
        ) {
            PullToRefreshDefaults.Indicator(state = state, isRefreshing = isRefreshing)
        }
    }
}

Custom indicators with default transforms can be seen in

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.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

var itemCount by remember { mutableIntStateOf(15) }
var isRefreshing by remember { mutableStateOf(false) }
val state = rememberPullToRefreshState()
val coroutineScope = rememberCoroutineScope()
val onRefresh: () -> Unit = {
    isRefreshing = true
    coroutineScope.launch {
        delay(1500)
        itemCount += 5
        isRefreshing = false
    }
}

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text("Title") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = onRefresh) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    PullToRefreshBox(
        modifier = Modifier.padding(it),
        state = state,
        isRefreshing = isRefreshing,
        onRefresh = onRefresh,
        indicator = {
            PullToRefreshDefaults.IndicatorBox(
                state = state,
                isRefreshing = isRefreshing,
                modifier = Modifier.align(Alignment.TopCenter),
                elevation = 0.dp
            ) {
                if (isRefreshing) {
                    CircularProgressIndicator()
                } else {
                    CircularProgressIndicator(
                        progress = { state.distanceFraction },
                        trackColor = ProgressIndicatorDefaults.circularIndeterminateTrackColor,
                    )
                }
            }
        }
    ) {
        LazyColumn(Modifier.fillMaxSize()) {
            items(itemCount) { ListItem({ Text(text = "Item ${itemCount - it}") }) }
        }
    }
}
Parameters
isRefreshing: Boolean

whether a refresh is occurring

onRefresh: () -> Unit

callback invoked when the user gesture crosses the threshold, thereby requesting a refresh.

modifier: Modifier = Modifier

the Modifier to be applied to this container

state: PullToRefreshState = rememberPullToRefreshState()

the state that keeps track of distance pulled

contentAlignment: Alignment = Alignment.TopStart

The default alignment inside the Box.

indicator: @Composable BoxScope.() -> Unit = { Indicator( modifier = Modifier.align(Alignment.TopCenter), isRefreshing = isRefreshing, state = state ) }

the indicator that will be drawn on top of the content when the user begins a pull or a refresh is occurring

content: @Composable BoxScope.() -> Unit

the content of the pull refresh container, typically a scrollable layout such as LazyColumn or a layout using Modifier.verticalScroll

PullToRefreshState

@ExperimentalMaterial3Api
fun PullToRefreshState(): PullToRefreshState

Creates a PullToRefreshState.

Note that in most cases, you are advised to use rememberPullToRefreshState when in composition.

rememberPullToRefreshState

@Composable
@ExperimentalMaterial3Api
fun rememberPullToRefreshState(): PullToRefreshState

Create and remember the default PullToRefreshState.

Extension functions

@ExperimentalMaterial3Api
fun Modifier.pullToRefresh(
    isRefreshing: Boolean,
    state: PullToRefreshState,
    enabled: Boolean = true,
    threshold: Dp = PullToRefreshDefaults.PositionalThreshold,
    onRefresh: () -> Unit
): Modifier

A Modifier that adds nested scroll to a container to support a pull-to-refresh gesture. When the user pulls a distance greater than threshold and releases the gesture, onRefresh is invoked. PullToRefreshBox applies this automatically.

Parameters
isRefreshing: Boolean

whether a refresh is occurring or not, if there is no gesture in progress when isRefreshing is false the state.distanceFraction will animate to 0f, otherwise it will animate to 1f

state: PullToRefreshState

state that keeps track of the distance pulled

enabled: Boolean = true

whether nested scroll events should be consumed by this modifier

threshold: Dp = PullToRefreshDefaults.PositionalThreshold

how much distance can be scrolled down before onRefresh is invoked

onRefresh: () -> Unit

callback that is invoked when the distance pulled is greater than threshold