PullToRefreshBox
Component in Material 3 Compose
[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].
@sample androidx.compose.material3.samples.PullToRefreshSample
Using a [androidx.compose.material3.LoadingIndicator] as the [PullToRefreshBox] indicator can be done like this
@sample androidx.compose.material3.samples.PullToRefreshWithLoadingIndicatorSample
View models can be used as source as truth as shown in
@sample androidx.compose.material3.samples.PullToRefreshViewModelSample
A custom state implementation can be initialized like this
@sample androidx.compose.material3.samples.PullToRefreshSampleCustomState
Scaling behavior can be implemented like this
@sample androidx.compose.material3.samples.PullToRefreshScalingSample
Custom indicators with default transforms can be seen in
Last updated:
Installation
dependencies {
implementation("androidx.compose.material3:material3:1.4.0-alpha02")
}
Overloads
@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
)
Parameters
name | description |
---|---|
isRefreshing | whether a refresh is occurring |
onRefresh | callback invoked when the user gesture crosses the threshold, thereby requesting a refresh. |
modifier | the [Modifier] to be applied to this container |
state | the state that keeps track of distance pulled |
contentAlignment | The default alignment inside the Box. |
indicator | the indicator that will be drawn on top of the content when the user begins a pull or a refresh is occurring |
content | the content of the pull refresh container, typically a scrollable layout such as [LazyColumn] or a layout using [Modifier.verticalScroll] |
Code Examples
PullToRefreshSample
@Composable
@Preview
@OptIn(ExperimentalMaterial3Api::class)
fun PullToRefreshSample() {
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}") }) }
}
}
}
}
PullToRefreshWithLoadingIndicatorSample
@Composable
@Preview
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
fun PullToRefreshWithLoadingIndicatorSample() {
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}") }) }
}
}
}
}
PullToRefreshViewModelSample
@Composable
@Preview
@OptIn(ExperimentalMaterial3Api::class)
fun PullToRefreshViewModelSample() {
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}") })
}
}
}
}
}
}
PullToRefreshSampleCustomState
@Composable
@Preview
@OptIn(ExperimentalMaterial3Api::class)
fun PullToRefreshSampleCustomState() {
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}") }) }
}
}
}
}
}
PullToRefreshScalingSample
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun PullToRefreshScalingSample() {
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)
}
}
}
}
PullToRefreshCustomIndicatorWithDefaultTransform
@Composable
@Preview
@OptIn(ExperimentalMaterial3Api::class)
fun PullToRefreshCustomIndicatorWithDefaultTransform() {
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}") }) }
}
}
}
}