I am reading a huge file in batches (from starting from the first line and down). When a user reaches the end of a batch, I want them to be able to swipe up (drag your finger from the bottom of the list to the top) to load more content.
Material3 pullToRefresh
works fine, but only if you are pulling from top to bottom. Is there a way to reverse the direction? I can't figure it out. I can detect when I am at the bottom of the list, but I would like the pull... so the action is intentional.
val pullToRefreshState = rememberPullToRefreshState()
LazyColumnScrollbar(
state = listState,
modifier = Modifier.pullToRefresh(
isRefreshing = isLoading.value,
state = pullToRefreshState,
enabled = true,
onRefresh = { refresh() },
*** direction = BottomTop *** // something like that
)
) {
LazyColumn(){}
}
Or is there any alternative to that?
It is not possible out of the box, but you can achieve the desired behavior by creating your own InversePullToRefreshBox
Composable. I created below file from the original source code and made some necessary adjustments.
InversePullToRefresh.kt
package com.example.playground
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
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.nestedScrollModifierNode
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
@Composable
@ExperimentalMaterial3Api
fun InversePullToRefreshBox(
isRefreshing: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
state: PullToRefreshState = rememberPullToRefreshState(),
contentAlignment: Alignment = Alignment.TopStart,
indicator: @Composable BoxScope.() -> Unit = {
Indicator(
modifier = Modifier
.align(Alignment.BottomCenter)
.rotate(180f),
isRefreshing = isRefreshing,
state = state
)
},
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier.pullToRefresh(state = state, isRefreshing = isRefreshing, onRefresh = onRefresh),
contentAlignment = contentAlignment
) {
content()
indicator()
}
}
@ExperimentalMaterial3Api
fun Modifier.pullToRefresh(
isRefreshing: Boolean,
state: PullToRefreshState,
enabled: Boolean = true,
threshold: Dp = PullToRefreshDefaults.PositionalThreshold,
onRefresh: () -> Unit,
): Modifier =
this then
PullToRefreshElement(
state = state,
isRefreshing = isRefreshing,
enabled = enabled,
onRefresh = onRefresh,
threshold = threshold
)
@OptIn(ExperimentalMaterial3Api::class)
internal data class PullToRefreshElement(
val isRefreshing: Boolean,
val onRefresh: () -> Unit,
val enabled: Boolean,
val state: PullToRefreshState,
val threshold: Dp,
) : ModifierNodeElement<PullToRefreshModifierNode>() {
override fun create() =
PullToRefreshModifierNode(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
enabled = enabled,
state = state,
threshold = threshold
)
override fun update(node: PullToRefreshModifierNode) {
node.onRefresh = onRefresh
node.enabled = enabled
node.state = state
node.threshold = threshold
if (node.isRefreshing != isRefreshing) {
node.isRefreshing = isRefreshing
node.update()
}
}
override fun InspectorInfo.inspectableProperties() {
name = "PullToRefreshModifierNode"
properties["isRefreshing"] = isRefreshing
properties["onRefresh"] = onRefresh
properties["enabled"] = enabled
properties["state"] = state
properties["threshold"] = threshold
}
}
@OptIn(ExperimentalMaterial3Api::class)
internal class PullToRefreshModifierNode(
var isRefreshing: Boolean,
var onRefresh: () -> Unit,
var enabled: Boolean,
var state: PullToRefreshState,
var threshold: Dp,
) : DelegatingNode(), CompositionLocalConsumerModifierNode, NestedScrollConnection {
private var nestedScrollNode: DelegatableNode =
nestedScrollModifierNode(
connection = this,
dispatcher = null,
)
private var verticalOffset by mutableFloatStateOf(0f)
private var distancePulled by mutableFloatStateOf(0f)
private val adjustedDistancePulled: Float
get() = distancePulled * DragMultiplier
private val thresholdPx
get() = with(currentValueOf(LocalDensity)) { threshold.roundToPx() }
private val progress
get() = adjustedDistancePulled / thresholdPx
override fun onAttach() {
delegate(nestedScrollNode)
coroutineScope.launch {
if (isRefreshing) {
state.snapTo(1f)
} else {
state.snapTo(0f)
}
}
}
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset =
when {
state.isAnimating -> Offset.Zero
!enabled -> Offset.Zero
// Swiping up
source == NestedScrollSource.UserInput && available.y > 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset =
when {
state.isAnimating -> Offset.Zero
!enabled -> Offset.Zero
// Swiping down
source == NestedScrollSource.UserInput -> {
val newOffset = consumeAvailableOffset(available)
coroutineScope.launch { state.snapTo(verticalOffset / thresholdPx) }
newOffset
}
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
fun update() {
coroutineScope.launch {
if (!isRefreshing) {
animateToHidden()
} else {
animateToThreshold()
}
}
}
/** Helper method for nested scroll connection */
private fun consumeAvailableOffset(available: Offset): Offset {
val y =
if (isRefreshing) 0f
else {
val newOffset = (distancePulled + available.y).coerceAtMost(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
verticalOffset = abs(calculateVerticalOffset())
dragConsumed
}
return Offset(0f, y)
}
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
private suspend fun onRelease(velocity: Float): Float {
if (isRefreshing) return 0f // Already refreshing, do nothing
// Trigger refresh
if (abs(adjustedDistancePulled) > thresholdPx) {
animateToThreshold()
onRefresh()
} else {
animateToHidden()
}
val consumed =
when {
// We are flinging without having dragged the pull refresh (for example a fling
// inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent
// the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
private fun calculateVerticalOffset(): Float =
when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= thresholdPx -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = thresholdPx * tensionPercent
thresholdPx + extraOffset
}
}
private suspend fun animateToThreshold() {
state.animateToThreshold()
distancePulled = thresholdPx.toFloat()
verticalOffset = thresholdPx.toFloat()
}
private suspend fun animateToHidden() {
state.animateToHidden()
distancePulled = 0f
verticalOffset = 0f
}
}
/**
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which is
* used in calculating the indicator position (when the adjusted distance pulled is less than the
* refresh threshold, it is the indicator position, otherwise the indicator position is derived from
* the progress).
*/
private const val DragMultiplier = 0.5f
Usage:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InversePullRefreshBoxDemo() {
var isLoading by remember { mutableStateOf(false) }
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(isLoading) {
if (isLoading) {
delay(1000)
isLoading = false
}
}
InversePullToRefreshBox(
modifier = Modifier.fillMaxSize(),
state = pullToRefreshState,
isRefreshing = isLoading,
onRefresh = { isLoading = true },
contentAlignment = Alignment.Center,
) {
LazyColumn() {
items(50) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "ITEM $it"
)
}
}
}
}
Output:
Note
These Compose APIs are still experimental. When using this approach, it might be necessary in the future to refactor the InversePullToRefresh.kt
file.