mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-11-13 12:36:36 +01:00
Removes pull to refresh hack since we can now fully migrate
This commit is contained in:
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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<Float>(300, easing = LinearEasing)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -21,61 +21,71 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.feeds
|
package com.vitorpamplona.amethyst.ui.feeds
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.pullrefresh.pullRefresh
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.material3.pullrefresh.rememberPullRefreshState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RefresheableBox(
|
fun RefresheableBox(
|
||||||
invalidateableContent: InvalidatableContent,
|
invalidateableContent: InvalidatableContent,
|
||||||
enablePullRefresh: Boolean = true,
|
enablePullRefresh: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
RefresheableBox(
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
enablePullRefresh = enablePullRefresh,
|
val scope = rememberCoroutineScope()
|
||||||
onRefresh = { invalidateableContent.invalidateData() },
|
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,
|
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user