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,
    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
Modifier
@ExperimentalMaterial3Api
Modifier.pullToRefreshIndicator(
    state: PullToRefreshState,
    isRefreshing: Boolean,
    threshold: Dp,
    shape: Shape,
    containerColor: Color
)

A Modifier that handles the size, offset, clipping, shadow, and background drawing of a pull-to-refresh indicator, useful when implementing custom indicators.

Cmn

Top-level functions

PullToRefreshBox

@Composable
@ExperimentalMaterial3Api
fun PullToRefreshBox(
    isRefreshing: Boolean,
    onRefresh: () -> Unit,
    modifier: Modifier = Modifier,
    state: PullToRefreshState = rememberPullToRefreshState(),
    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.

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.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll

var itemCount by remember { mutableStateOf(15) }
val state = rememberPullToRefreshState()
if (state.isRefreshing) {
    LaunchedEffect(true) {
        // fetch something
        delay(1500)
        itemCount += 5
        state.endRefresh()
    }
}
Scaffold(
    modifier = Modifier.nestedScroll(state.nestedScrollConnection),
    topBar = {
        TopAppBar(
            title = { Text("TopAppBar") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = { state.startRefresh() }) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    Box(Modifier.padding(it)) {
        LazyColumn(Modifier.fillMaxSize()) {
            if (!state.isRefreshing) {
                items(itemCount) {
                    ListItem({ Text(text = "Item ${itemCount - it}") })
                }
            }
        }
        PullToRefreshContainer(
            modifier = Modifier.align(Alignment.TopCenter),
            state = state,
        )
    }
}

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(1000) // 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.animate
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.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity

var itemCount by remember { mutableStateOf(15) }
val state = remember {
    object : PullToRefreshState {
        override val positionalThreshold: Float = 100f
        override val progress get() = verticalOffset / positionalThreshold
        override var verticalOffset: Float by mutableFloatStateOf(0f)
        override var isRefreshing: Boolean by mutableStateOf(false)

        override fun startRefresh() {
            isRefreshing = true
        }
        override fun endRefresh() {
            isRefreshing = false
        }

        // Provide logic for the PullRefreshContainer to consume scrolls within a nested scroll
        override var nestedScrollConnection: NestedScrollConnection =
            object : NestedScrollConnection {
                // Pre and post scroll provide the drag logic for PullRefreshContainer.
                override fun onPreScroll(
                    available: Offset,
                    source: NestedScrollSource,
                ): Offset = when {
                    source == NestedScrollSource.UserInput && available.y < 0 -> {
                        // Swiping up
                        val y = if (isRefreshing) 0f else {
                            val newOffset = (verticalOffset + available.y).coerceAtLeast(0f)
                            val dragConsumed = newOffset - verticalOffset
                            verticalOffset = newOffset
                            dragConsumed
                        }
                        Offset(0f, y)
                    }

                    else -> Offset.Zero
                }

                override fun onPostScroll(
                    consumed: Offset,
                    available: Offset,
                    source: NestedScrollSource
                ): Offset = when {
                    source == NestedScrollSource.UserInput && available.y > 0 -> {
                        // Swiping Down
                        val y = if (isRefreshing) 0f else {
                            val newOffset = (verticalOffset + available.y).coerceAtLeast(0f)
                            val dragConsumed = newOffset - verticalOffset
                            verticalOffset = newOffset
                            dragConsumed
                        }
                        Offset(0f, y)
                    }

                    else -> Offset.Zero
                }

                // Pre-Fling is called when the user releases a drag. This is where you can provide
                // refresh logic, and verify exceeding positional threshold.
                override suspend fun onPreFling(available: Velocity): Velocity {
                    if (isRefreshing) return Velocity.Zero
                    if (verticalOffset > positionalThreshold) {
                        startRefresh()
                        itemCount += 5
                        endRefresh()
                    }
                    animate(verticalOffset, 0f) { value, _ ->
                        verticalOffset = value
                    }
                    val consumed = when {
                        verticalOffset == 0f -> 0f
                        available.y < 0f -> 0f
                        else -> available.y
                    }
                    return Velocity(0f, consumed)
                }
            }
    }
}
Scaffold(
    modifier = Modifier.nestedScroll(state.nestedScrollConnection),
    topBar = {
        TopAppBar(
            title = { Text("TopAppBar") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = { state.startRefresh() }) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    Box(Modifier.padding(it)) {
        LazyColumn(Modifier.fillMaxSize()) {
            if (!state.isRefreshing) {
                items(itemCount) {
                    ListItem({ Text(text = "Item ${itemCount - it}") })
                }
            }
        }
        PullToRefreshContainer(
            modifier = Modifier.align(Alignment.TopCenter),
            state = state,
        )
    }
}

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.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll

var itemCount by remember { mutableStateOf(15) }
val state = rememberPullToRefreshState()
if (state.isRefreshing) {
    LaunchedEffect(true) {
        // fetch something
        delay(1500)
        itemCount += 5
        state.endRefresh()
    }
}
val scaleFraction = if (state.isRefreshing) 1f else
    LinearOutSlowInEasing.transform(state.progress).coerceIn(0f, 1f)

Scaffold(
    modifier = Modifier.nestedScroll(state.nestedScrollConnection),
    topBar = {
        TopAppBar(
            title = { Text("TopAppBar") },
            // Provide an accessible alternative to trigger refresh.
            actions = {
                IconButton(onClick = { state.startRefresh() }) {
                    Icon(Icons.Filled.Refresh, "Trigger Refresh")
                }
            }
        )
    }
) {
    Box(Modifier.padding(it)) {
        LazyColumn(Modifier.fillMaxSize()) {
            if (!state.isRefreshing) {
                items(itemCount) {
                    ListItem({ Text(text = "Item ${itemCount - it}") })
                }
            }
        }
        PullToRefreshContainer(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .graphicsLayer(scaleX = scaleFraction, scaleY = scaleFraction),
            state = state,
        )
    }
}
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

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.

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

pullToRefreshIndicator

@ExperimentalMaterial3Api
fun Modifier.pullToRefreshIndicator(
    state: PullToRefreshState,
    isRefreshing: Boolean,
    threshold: Dp = PullToRefreshDefaults.PositionalThreshold,
    shape: Shape = PullToRefreshDefaults.shape,
    containerColor: Color = Color.Unspecified
): Modifier

A Modifier that handles the size, offset, clipping, shadow, and background drawing of a pull-to-refresh indicator, useful when implementing custom indicators. PullToRefreshDefaults.Indicator applies this automatically.

Parameters
state: PullToRefreshState

the state of this modifier, will use state.distanceFraction and threshold to calculate the offset

isRefreshing: Boolean

whether a refresh is occurring

threshold: Dp = PullToRefreshDefaults.PositionalThreshold

how much the indicator can be pulled down before a refresh is triggered on release

shape: Shape = PullToRefreshDefaults.shape

the Shape of this indicator

containerColor: Color = Color.Unspecified

the container color of this indicator