Created
July 14, 2023 14:36
-
-
Save Del-S/f0d4ee78eba893ecde7b15393dfff4c1 to your computer and use it in GitHub Desktop.
Android compose reversible pull refresh state.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.material.ExperimentalMaterialApi | |
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.platform.debugInspectorInfo | |
import androidx.compose.ui.platform.inspectable | |
import androidx.compose.ui.unit.Velocity | |
/** | |
* A nested scroll modifier that provides scroll events to [state]. | |
* | |
* Note that this modifier must be added above a scrolling container, such as a lazy column, in | |
* order to receive scroll events. For example: | |
* | |
* @sample androidx.compose.material.samples.PullRefreshSample | |
* | |
* @param state The [ReversiblePullRefreshState] associated with this pull-to-refresh component. | |
* The state will be updated by this modifier. | |
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored. | |
* @param reverseLayout A boolean controlling whether the pull refresh should be reversed or not. | |
*/ | |
@ExperimentalMaterialApi | |
fun Modifier.reversiblePullRefresh( | |
state: ReversiblePullRefreshState, | |
enabled: Boolean = true, | |
reverseLayout: Boolean = false, | |
) = inspectable(inspectorInfo = debugInspectorInfo { | |
name = "pullRefresh" | |
properties["state"] = state | |
properties["enabled"] = enabled | |
properties["reverseLayout"] = reverseLayout | |
}) { | |
Modifier.reversiblePullRefresh(state::onPull, state::onRelease, enabled, reverseLayout) | |
} | |
/** | |
* A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom | |
* pull refresh components. | |
* | |
* Note that this modifier must be added above a scrolling container, such as a lazy column, in | |
* order to receive scroll events. For example: | |
* | |
* @sample androidx.compose.material.samples.PullRefreshSample | |
* | |
* @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument. | |
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling | |
* down despite being at the top of a scrollable component), whereas negative delta (swiping up) is | |
* dispatched first (in case it is needed to push the indicator back up), and then the unconsumed | |
* delta is passed on to the child. The callback returns how much delta was consumed. | |
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument. | |
* The callback returns how much velocity was consumed - in most cases this should only consume | |
* velocity if pull refresh has been dragged already and the velocity is positive (the fling is | |
* downwards), as an upwards fling should typically still scroll a scrollable component beneath the | |
* pullRefresh. This is invoked before any remaining velocity is passed to the child. | |
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither | |
* [onPull] nor [onRelease] will be invoked. | |
* @param reverseLayout A boolean controlling whether the pull refresh should be reversed or not. | |
*/ | |
@ExperimentalMaterialApi | |
fun Modifier.reversiblePullRefresh( | |
onPull: (pullDelta: Float) -> Float, | |
onRelease: suspend (flingVelocity: Float) -> Float, | |
enabled: Boolean = true, | |
reverseLayout: Boolean = false, | |
) = inspectable(inspectorInfo = debugInspectorInfo { | |
name = "pullRefresh" | |
properties["onPull"] = onPull | |
properties["onRelease"] = onRelease | |
properties["enabled"] = enabled | |
properties["reverseLayout"] = reverseLayout | |
}) { | |
Modifier.nestedScroll(ReversiblePullRefreshNestedScrollConnection(onPull, onRelease, enabled, reverseLayout)) | |
} | |
private class ReversiblePullRefreshNestedScrollConnection( | |
private val onPull: (pullDelta: Float) -> Float, | |
private val onRelease: suspend (flingVelocity: Float) -> Float, | |
private val enabled: Boolean, | |
private val reverseLayout: Boolean, | |
) : NestedScrollConnection { | |
override fun onPreScroll( | |
available: Offset, | |
source: NestedScrollSource | |
): Offset = when { | |
!enabled -> Offset.Zero | |
!reverseLayout && source == NestedScrollSource.Drag && available.y < 0 -> Offset( | |
0f, | |
onPull(available.y) | |
) // Swiping up | |
reverseLayout && source == NestedScrollSource.Drag && available.y > 0 -> Offset( | |
0f, | |
onPull(available.y) | |
) // Pulling down | |
else -> Offset.Zero | |
} | |
override fun onPostScroll( | |
consumed: Offset, | |
available: Offset, | |
source: NestedScrollSource | |
): Offset = when { | |
!enabled -> Offset.Zero | |
!reverseLayout && source == NestedScrollSource.Drag && available.y > 0 -> Offset( | |
0f, | |
onPull(available.y) | |
) // Pulling down | |
reverseLayout && source == NestedScrollSource.Drag && available.y < 0 -> Offset( | |
0f, | |
onPull(available.y) | |
) // Swiping up | |
else -> Offset.Zero | |
} | |
override suspend fun onPreFling(available: Velocity): Velocity { | |
return Velocity(0f, onRelease(available.y)) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.animation.Crossfade | |
import androidx.compose.animation.core.LinearEasing | |
import androidx.compose.animation.core.LinearOutSlowInEasing | |
import androidx.compose.animation.core.animateFloatAsState | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.Canvas | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.material.CircularProgressIndicator | |
import androidx.compose.material.ExperimentalMaterialApi | |
import androidx.compose.material.MaterialTheme | |
import androidx.compose.material.Surface | |
import androidx.compose.material.contentColorFor | |
import androidx.compose.material.pullrefresh.PullRefreshState | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.Immutable | |
import androidx.compose.runtime.derivedStateOf | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.drawWithContent | |
import androidx.compose.ui.draw.rotate | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.geometry.Rect | |
import androidx.compose.ui.geometry.center | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.Path | |
import androidx.compose.ui.graphics.PathFillType | |
import androidx.compose.ui.graphics.StrokeCap | |
import androidx.compose.ui.graphics.drawscope.DrawScope | |
import androidx.compose.ui.graphics.drawscope.Stroke | |
import androidx.compose.ui.graphics.drawscope.clipRect | |
import androidx.compose.ui.graphics.drawscope.rotate | |
import androidx.compose.ui.graphics.graphicsLayer | |
import androidx.compose.ui.platform.debugInspectorInfo | |
import androidx.compose.ui.platform.inspectable | |
import androidx.compose.ui.semantics.semantics | |
import androidx.compose.ui.unit.dp | |
import kotlin.math.abs | |
import kotlin.math.max | |
import kotlin.math.min | |
import kotlin.math.pow | |
/** | |
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator | |
* based on the given [ReversiblePullRefreshState]. | |
* | |
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample | |
* | |
* @param state The [ReversiblePullRefreshState] which determines the position of the indicator. | |
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not. | |
* @param reverseLayout A boolean controlling whether the pull refresh should be reversed or not. | |
*/ | |
@ExperimentalMaterialApi | |
fun Modifier.reversiblePullRefreshIndicatorTransform( | |
state: ReversiblePullRefreshState, | |
scale: Boolean = false, | |
reverseLayout: Boolean = false, | |
) = inspectable(inspectorInfo = debugInspectorInfo { | |
name = "reversiblePullRefreshIndicatorTransform" | |
properties["state"] = state | |
properties["scale"] = scale | |
}) { | |
Modifier | |
.rotate( | |
if (reverseLayout) { | |
180f | |
} else { | |
0f | |
} | |
) | |
// Essentially we only want to clip the at the top, so the indicator will not appear when | |
// the position is 0. It is preferable to clip the indicator as opposed to the layout that | |
// contains the indicator, as this would also end up clipping shadows drawn by items in a | |
// list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE | |
// for the other dimensions to allow for more room for elevation / arbitrary indicators - we | |
// only ever really want to clip at the top edge. | |
.drawWithContent { | |
clipRect( | |
top = 0f, | |
left = -Float.MAX_VALUE, | |
right = Float.MAX_VALUE, | |
bottom = Float.MAX_VALUE | |
) { | |
this@drawWithContent.drawContent() | |
} | |
} | |
.graphicsLayer { | |
translationY = if (reverseLayout) { | |
abs(state.position) - size.height | |
} else { | |
state.position - size.height | |
} | |
if (scale && !state.refreshing) { | |
val scaleFraction = LinearOutSlowInEasing | |
.transform(state.position / state.threshold) | |
.coerceIn(0f, 1f) | |
scaleX = scaleFraction | |
scaleY = scaleFraction | |
} | |
} | |
} | |
/** | |
* The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. | |
* | |
* @sample androidx.compose.material.samples.PullRefreshSample | |
* | |
* @param refreshing A boolean representing whether a refresh is occurring. | |
* @param state The [PullRefreshState] which controls where and how the indicator will be drawn. | |
* @param modifier Modifiers for the indicator. | |
* @param backgroundColor The color of the indicator's background. | |
* @param contentColor The color of the indicator's arc and arrow. | |
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not. | |
* @param reverseLayout A boolean controlling whether the pull refresh should be reversed or not. | |
*/ | |
@Composable | |
@ExperimentalMaterialApi | |
fun ReversiblePullRefreshIndicator( | |
refreshing: Boolean, | |
state: ReversiblePullRefreshState, | |
modifier: Modifier = Modifier, | |
backgroundColor: Color = MaterialTheme.colors.surface, | |
contentColor: Color = contentColorFor(backgroundColor), | |
scale: Boolean = false, | |
reverseLayout: Boolean = false, | |
) { | |
val showElevation by remember(refreshing, state) { | |
derivedStateOf { refreshing || abs(state.position) > 0.5f } | |
} | |
Surface( | |
modifier = modifier | |
.size(IndicatorSize) | |
.reversiblePullRefreshIndicatorTransform(state, scale, reverseLayout), | |
shape = SpinnerShape, | |
color = backgroundColor, | |
elevation = if (showElevation) Elevation else 0.dp, | |
) { | |
Crossfade( | |
targetState = refreshing, | |
animationSpec = tween(durationMillis = CrossfadeDurationMs), | |
label = "" | |
) { refreshing -> | |
Box( | |
modifier = Modifier.fillMaxSize(), | |
contentAlignment = Alignment.Center | |
) { | |
val spinnerSize = (ArcRadius + StrokeWidth).times(2) | |
if (refreshing) { | |
CircularProgressIndicator( | |
color = contentColor, | |
strokeWidth = StrokeWidth, | |
modifier = Modifier.size(spinnerSize), | |
) | |
} else { | |
ReversibleCircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) | |
} | |
} | |
} | |
} | |
} | |
/** | |
* Modifier.size MUST be specified. | |
*/ | |
@Composable | |
@ExperimentalMaterialApi | |
private fun ReversibleCircularArrowIndicator( | |
state: ReversiblePullRefreshState, | |
color: Color, | |
modifier: Modifier, | |
) { | |
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } | |
val targetAlpha by remember(state) { | |
derivedStateOf { | |
if (state.progress >= 1f) MaxAlpha else MinAlpha | |
} | |
} | |
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) | |
// Empty semantics for tests | |
Canvas(modifier.semantics {}) { | |
val values = ArrowValues(state.progress) | |
val alpha = alphaState.value | |
rotate(degrees = values.rotation) { | |
val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f | |
val arcBounds = Rect( | |
size.center.x - arcRadius, | |
size.center.y - arcRadius, | |
size.center.x + arcRadius, | |
size.center.y + arcRadius | |
) | |
drawArc( | |
color = color, | |
alpha = alpha, | |
startAngle = values.startAngle, | |
sweepAngle = values.endAngle - values.startAngle, | |
useCenter = false, | |
topLeft = arcBounds.topLeft, | |
size = arcBounds.size, | |
style = Stroke( | |
width = StrokeWidth.toPx(), | |
cap = StrokeCap.Square | |
) | |
) | |
drawArrow(path, arcBounds, color, alpha, values) | |
} | |
} | |
} | |
@Immutable | |
private class ArrowValues( | |
val rotation: Float, | |
val startAngle: Float, | |
val endAngle: Float, | |
val scale: Float | |
) | |
private fun ArrowValues(progress: Float): ArrowValues { | |
// Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. | |
val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 | |
// 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 | |
// Calculations based on SwipeRefreshLayout specification. | |
val endTrim = adjustedPercent * MaxProgressArc | |
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f | |
val startAngle = rotation * 360 | |
val endAngle = (rotation + endTrim) * 360 | |
val scale = min(1f, adjustedPercent) | |
return ArrowValues(rotation, startAngle, endAngle, scale) | |
} | |
private fun DrawScope.drawArrow( | |
arrow: Path, | |
bounds: Rect, | |
color: Color, | |
alpha: Float, | |
values: ArrowValues | |
) { | |
arrow.reset() | |
arrow.moveTo(0f, 0f) // Move to left corner | |
arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner | |
// Line to tip of arrow | |
arrow.lineTo( | |
x = ArrowWidth.toPx() * values.scale / 2, | |
y = ArrowHeight.toPx() * values.scale | |
) | |
val radius = min(bounds.width, bounds.height) / 2f | |
val inset = ArrowWidth.toPx() * values.scale / 2f | |
arrow.translate( | |
Offset( | |
x = radius + bounds.center.x - inset, | |
y = bounds.center.y + StrokeWidth.toPx() / 2f | |
) | |
) | |
arrow.close() | |
rotate(degrees = values.endAngle) { | |
drawPath(path = arrow, color = color, alpha = alpha) | |
} | |
} | |
private const val CrossfadeDurationMs = 100 | |
private const val MaxProgressArc = 0.8f | |
private val IndicatorSize = 40.dp | |
private val SpinnerShape = CircleShape | |
private val ArcRadius = 7.5.dp | |
private val StrokeWidth = 2.5.dp | |
private val ArrowWidth = 10.dp | |
private val ArrowHeight = 5.dp | |
private val Elevation = 6.dp | |
// Values taken from SwipeRefreshLayout | |
private const val MinAlpha = 0.3f | |
private const val MaxAlpha = 1f | |
private val AlphaTween = tween<Float>(300, easing = LinearEasing) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.animation.core.animate | |
import androidx.compose.foundation.MutatorMutex | |
import androidx.compose.material.ExperimentalMaterialApi | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.SideEffect | |
import androidx.compose.runtime.State | |
import androidx.compose.runtime.derivedStateOf | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.runtime.rememberUpdatedState | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
import kotlin.math.abs | |
import kotlin.math.pow | |
import kotlinx.coroutines.CoroutineScope | |
import kotlinx.coroutines.launch | |
/** | |
* Creates a [ReversiblePullRefreshState] that is remembered across compositions. | |
* | |
* Changes to [refreshing] will result in [ReversiblePullRefreshState] being updated. | |
* | |
* @sample androidx.compose.material.samples.PullRefreshSample | |
* | |
* @param refreshing A boolean representing whether a refresh is currently occurring. | |
* @param onRefresh The function to be called to trigger a refresh. | |
* @param reverseLayout A boolean controlling whether the pull refresh should be reversed or not. | |
* @param refreshThreshold The threshold below which, if a release | |
* occurs, [onRefresh] will be called. | |
* @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This | |
* offset corresponds to the position of the bottom of the indicator. | |
*/ | |
@Composable | |
@ExperimentalMaterialApi | |
fun rememberReversiblePullRefreshState( | |
refreshing: Boolean, | |
onRefresh: () -> Unit, | |
reverseLayout: Boolean = false, | |
refreshThreshold: Dp = if (reverseLayout) { | |
-PullRefreshDefaults.RefreshThreshold | |
} else { | |
PullRefreshDefaults.RefreshThreshold | |
}, | |
refreshingOffset: Dp = if (reverseLayout) { | |
-PullRefreshDefaults.RefreshingOffset | |
} else { | |
PullRefreshDefaults.RefreshingOffset | |
}, | |
): ReversiblePullRefreshState { | |
require(reverseLayout || refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } | |
require(!reverseLayout || refreshThreshold < 0.dp) { "The refresh trigger must be lower than zero!" } | |
val scope = rememberCoroutineScope() | |
val onRefreshState = rememberUpdatedState(onRefresh) | |
val thresholdPx: Float | |
val refreshingOffsetPx: Float | |
with(LocalDensity.current) { | |
thresholdPx = refreshThreshold.toPx() | |
refreshingOffsetPx = refreshingOffset.toPx() | |
} | |
val state = remember(scope) { | |
ReversiblePullRefreshState(scope, onRefreshState, reverseLayout, refreshingOffsetPx, thresholdPx) | |
} | |
SideEffect { | |
state.setRefreshing(refreshing) | |
state.setThreshold(thresholdPx) | |
state.setRefreshingOffset(refreshingOffsetPx) | |
} | |
return state | |
} | |
/** | |
* A state object that can be used in conjunction with [reversiblePullRefresh] to add pull-to-refresh | |
* behaviour to a scroll component. Based on Android's SwipeRefreshLayout. | |
* | |
* Provides [progress], a float representing how far the user has pulled as a percentage of the | |
* refreshThreshold. Values of one or less indicate that the user has not yet pulled past the | |
* threshold. Values greater than one indicate how far past the threshold the user has pulled. | |
* | |
* Can be used in conjunction with [reversiblePullRefreshIndicatorTransform] to implement | |
* Android-like pull-to-refresh behaviour with a custom indicator. | |
* | |
* Should be created using [rememberReversiblePullRefreshState]. | |
*/ | |
@ExperimentalMaterialApi | |
class ReversiblePullRefreshState internal constructor( | |
private val animationScope: CoroutineScope, | |
private val onRefreshState: State<() -> Unit>, | |
private val reverseLayout: Boolean, | |
refreshingOffset: Float, | |
threshold: Float | |
) { | |
/** | |
* A float representing how far the user has pulled as a percentage of the refreshThreshold. | |
* | |
* If the component has not been pulled at all, progress is zero. If the pull has reached | |
* halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has | |
* gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to | |
* two times the refreshThreshold. | |
*/ | |
val progress get() = adjustedDistancePulled / threshold | |
internal val refreshing get() = _refreshing | |
internal val position get() = _position | |
internal val threshold get() = _threshold | |
private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier } | |
private var _refreshing by mutableStateOf(false) | |
private var _position by mutableStateOf(0f) | |
private var distancePulled by mutableStateOf(0f) | |
private var _threshold by mutableStateOf(threshold) | |
private var _refreshingOffset by mutableStateOf(refreshingOffset) | |
internal fun onPull(pullDelta: Float): Float { | |
if (_refreshing) return 0f // Already refreshing, do nothing. | |
val newOffset = if (reverseLayout) { | |
(distancePulled + pullDelta).coerceAtMost(0f) | |
} else { | |
(distancePulled + pullDelta).coerceAtLeast(0f) | |
} | |
val dragConsumed = newOffset - distancePulled | |
distancePulled = newOffset | |
_position = calculateIndicatorPosition() | |
return dragConsumed | |
} | |
internal fun onRelease(velocity: Float): Float { | |
if (refreshing) return 0f // Already refreshing, do nothing | |
when { | |
!reverseLayout && adjustedDistancePulled > threshold -> onRefreshState.value() | |
reverseLayout && adjustedDistancePulled < threshold -> onRefreshState.value() | |
} | |
animateIndicatorTo(0f) | |
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 | |
} | |
internal fun setRefreshing(refreshing: Boolean) { | |
if (_refreshing != refreshing) { | |
_refreshing = refreshing | |
distancePulled = 0f | |
animateIndicatorTo(if (refreshing) _refreshingOffset else 0f) | |
} | |
} | |
internal fun setThreshold(threshold: Float) { | |
_threshold = threshold | |
} | |
internal fun setRefreshingOffset(refreshingOffset: Float) { | |
if (_refreshingOffset != refreshingOffset) { | |
_refreshingOffset = refreshingOffset | |
if (refreshing) animateIndicatorTo(refreshingOffset) | |
} | |
} | |
// Make sure to cancel any existing animations when we launch a new one. We use this instead of | |
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra | |
// overhead of running through the animation pipeline instead of directly mutating the state. | |
private val mutatorMutex = MutatorMutex() | |
private fun animateIndicatorTo(offset: Float) = animationScope.launch { | |
mutatorMutex.mutate { | |
animate(initialValue = _position, targetValue = offset) { value, _ -> | |
_position = value | |
} | |
} | |
} | |
private fun calculateIndicatorPosition(): Float = when { | |
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. | |
!reverseLayout && adjustedDistancePulled <= threshold -> adjustedDistancePulled | |
reverseLayout && adjustedDistancePulled >= threshold -> 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 = threshold * tensionPercent | |
threshold + extraOffset | |
} | |
} | |
} | |
/** | |
* Default parameter values for [rememberReversiblePullRefreshState]. | |
*/ | |
@ExperimentalMaterialApi | |
object PullRefreshDefaults { | |
/** | |
* If the indicator is below this threshold offset when it is released, a refresh | |
* will be triggered. | |
*/ | |
val RefreshThreshold = 80.dp | |
/** | |
* The offset at which the indicator should be rendered whilst a refresh is occurring. | |
*/ | |
val RefreshingOffset = 56.dp | |
} | |
/** | |
* 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment