From d25e89f41db4b8735b22a6959a682d3c16eedab2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 24 Apr 2025 19:36:47 -0400 Subject: [PATCH] Removes pull to refresh hack since we can now fully migrate --- .../material3/pullrefresh/PullRefresh.kt | 122 --------- .../pullrefresh/PullRefreshIndicator.kt | 231 ----------------- .../PullRefreshIndicatorTransform.kt | 76 ------ .../material3/pullrefresh/PullRefreshState.kt | 235 ------------------ .../amethyst/ui/feeds/RefresheableBox.kt | 94 +++---- 5 files changed, 52 insertions(+), 706 deletions(-) delete mode 100644 amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt delete mode 100644 amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt delete mode 100644 amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt delete mode 100644 amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt deleted file mode 100644 index 5594a8418..000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package androidx.compose.material3.pullrefresh - -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.NestedScrollSource.Companion.Drag -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: - * - * @param state The [PullRefreshState] 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. - * @sample androidx.compose.material.samples.PullRefreshSample - */ -fun Modifier.pullRefresh( - state: PullRefreshState, - enabled: Boolean = true, -) = inspectable( - inspectorInfo = - debugInspectorInfo { - name = "pullRefresh" - properties["state"] = state - properties["enabled"] = enabled - }, -) { - Modifier.pullRefresh(state::onPull, state::onRelease, enabled) -} - -/** - * 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: - * - * @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. - * @sample androidx.compose.material.samples.CustomPullRefreshSample - */ -fun Modifier.pullRefresh( - onPull: (pullDelta: Float) -> Float, - onRelease: suspend (flingVelocity: Float) -> Float, - enabled: Boolean = true, -) = inspectable( - inspectorInfo = - debugInspectorInfo { - name = "pullRefresh" - properties["onPull"] = onPull - properties["onRelease"] = onRelease - properties["enabled"] = enabled - }, -) { - Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) -} - -private class PullRefreshNestedScrollConnection( - private val onPull: (pullDelta: Float) -> Float, - private val onRelease: suspend (flingVelocity: Float) -> Float, - private val enabled: Boolean, -) : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset = - when { - !enabled -> Offset.Zero - source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up - else -> Offset.Zero - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset = - when { - !enabled -> Offset.Zero - source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down - else -> Offset.Zero - } - - override suspend fun onPreFling(available: Velocity): Velocity = Velocity(0f, onRelease(available.y)) -} diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt deleted file mode 100644 index 5402915b8..000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package androidx.compose.material3.pullrefresh - -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.LinearEasing -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.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.contentColorFor -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.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.rotate -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 - -/** - * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. - * - * @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. - * @sample androidx.compose.material.samples.PullRefreshSample - */ -@Composable -fun PullRefreshIndicator( - refreshing: Boolean, - state: PullRefreshState, - modifier: Modifier = Modifier, - backgroundColor: Color = MaterialTheme.colorScheme.surface, - contentColor: Color = contentColorFor(backgroundColor), - scale: Boolean = false, -) { - val showElevation by - remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } } - - Surface( - modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale), - shape = SpinnerShape, - color = backgroundColor, - shadowElevation = if (showElevation) Elevation else 0.dp, - ) { - Crossfade( - targetState = refreshing, - animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS), - ) { 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 { - CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) - } - } - } - } -} - -/** Modifier.size MUST be specified. */ -@Composable -private fun CircularArrowIndicator( - state: PullRefreshState, - color: Color, - modifier: Modifier, -) { - val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } - - val targetAlpha by - remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } } - - 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 * MAX_PROGRESS_ARC - 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 CROSSFADE_DURATION_MS = 100 -private const val MAX_PROGRESS_ARC = 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 MIN_ALPHA = 0.3f -private const val MAX_ALPHA = 1f -private val AlphaTween = tween(300, easing = LinearEasing) diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt deleted file mode 100644 index 6e8b9853c..000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package androidx.compose.material3.pullrefresh - -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.debugInspectorInfo -import androidx.compose.ui.platform.inspectable - -/** - * A modifier for translating the position and scaling the size of a pull-to-refresh indicator based - * on the given [PullRefreshState]. - * - * @param state The [PullRefreshState] which determines the position of the indicator. - * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. - * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample - */ -fun Modifier.pullRefreshIndicatorTransform( - state: PullRefreshState, - scale: Boolean = false, -) = inspectable( - inspectorInfo = - debugInspectorInfo { - name = "pullRefreshIndicatorTransform" - properties["state"] = state - properties["scale"] = scale - }, -) { - Modifier - // 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 = state.position - size.height - - if (scale && !state.refreshing) { - val scaleFraction = - LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f) - scaleX = scaleFraction - scaleY = scaleFraction - } - } -} diff --git a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt b/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt deleted file mode 100644 index 98e3db5fe..000000000 --- a/amethyst/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshState.kt +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package androidx.compose.material3.pullrefresh - -import androidx.compose.animation.core.animate -import androidx.compose.foundation.MutatorMutex -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.mutableFloatStateOf -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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.pow - -/** - * Creates a [PullRefreshState] that is remembered across compositions. - * - * Changes to [refreshing] will result in [PullRefreshState] being updated. - * - * @param refreshing A boolean representing whether a refresh is currently occurring. - * @param onRefresh The function to be called to trigger a refresh. - * @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. - * @sample androidx.compose.material.samples.PullRefreshSample - */ -@Composable -fun rememberPullRefreshState( - refreshing: Boolean, - onRefresh: () -> Unit, - refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, - refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, -): PullRefreshState { - require(refreshThreshold > 0.dp) { "The refresh trigger must be greater 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) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) } - - SideEffect { - state.setRefreshing(refreshing) - state.setThreshold(thresholdPx) - state.setRefreshingOffset(refreshingOffsetPx) - } - - return state -} - -/** - * A state object that can be used in conjunction with [pullRefresh] 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 [pullRefreshIndicatorTransform] to implement Android-like - * pull-to-refresh behaviour with a custom indicator. - * - * Should be created using [rememberPullRefreshState]. - */ -class PullRefreshState - internal constructor( - private val animationScope: CoroutineScope, - private val onRefreshState: State<() -> Unit>, - 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 - - val refreshing - get() = _refreshing - - val position - get() = _position - - val threshold - get() = _threshold - - private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER } - - private var _refreshing by mutableStateOf(false) - private var _position by mutableFloatStateOf(0f) - private var distancePulled by mutableFloatStateOf(0f) - private var _threshold by mutableFloatStateOf(threshold) - private var refreshingOffsetState by mutableFloatStateOf(refreshingOffset) - - internal fun onPull(pullDelta: Float): Float { - if (_refreshing) return 0f // Already refreshing, do nothing. - - val newOffset = (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 - - if (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) refreshingOffsetState else 0f) - } - } - - internal fun setThreshold(threshold: Float) { - _threshold = threshold - } - - internal fun setRefreshingOffset(refreshingOffset: Float) { - if (refreshingOffsetState != refreshingOffset) { - refreshingOffsetState = 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. - 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 [rememberPullRefreshState]. */ -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 DRAG_MULTIPLIER = 0.5f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt index bf7d22e9b..5e78e5feb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt @@ -21,61 +21,71 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.pullrefresh.PullRefreshIndicator -import androidx.compose.material3.pullrefresh.pullRefresh -import androidx.compose.material3.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun RefresheableBox( invalidateableContent: InvalidatableContent, enablePullRefresh: Boolean = true, - content: @Composable () -> Unit, + content: @Composable BoxScope.() -> Unit, ) { - RefresheableBox( - enablePullRefresh = enablePullRefresh, - onRefresh = { invalidateableContent.invalidateData() }, + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val onRefresh: () -> Unit = { + isRefreshing = true + scope.launch { + invalidateableContent.invalidateData() + delay(500) + isRefreshing = false + } + } + + if (enablePullRefresh) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize(), + content = content, + ) + } else { + Box(Modifier.fillMaxSize(), content = content) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RefresheableBox( + onRefresh: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val onRefresh: () -> Unit = { + isRefreshing = true + scope.launch { + onRefresh() + delay(500) + isRefreshing = false + } + } + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize(), content = content, ) } - -@Composable -fun RefresheableBox( - enablePullRefresh: Boolean = true, - onRefresh: () -> Unit, - content: @Composable () -> Unit, -) { - var refreshing by remember { mutableStateOf(false) } - val refresh = { - refreshing = true - onRefresh() - refreshing = false - } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - - val modifier = - if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) - } else { - Modifier.fillMaxSize() - } - - Box(modifier) { - content() - - if (enablePullRefresh) { - PullRefreshIndicator( - refreshing = refreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) - } - } -}