PullToRefreshBox

Functions summary

Unit
@Composable
PullToRefreshBox(
    isRefreshing: Boolean,
    onRefresh: () -> Unit,
    modifier: Modifier,
    state: PullToRefreshState,
    contentAlignment: Alignment,
    indicator: @Composable BoxScope.() -> Unit,
    enabled: Boolean,
    threshold: Dp,
    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

Functions

PullToRefreshBox

@Composable
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, ) },
    enabled: Boolean = true,
    threshold: Dp = PullToRefreshDefaults.PositionalThreshold,
    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.mutableIntStateOf
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 mutableIntStateOf(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.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.graphics.graphicsLayer

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 {
        // 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

enabled: Boolean = true

whether nested scroll events should be consumed by this component

threshold: Dp = PullToRefreshDefaults.PositionalThreshold

how much distance can be scrolled down before onRefresh is invoked

content: @Composable BoxScope.() -> Unit

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