kopia lustrzana https://github.com/vitorpamplona/amethyst
232 wiersze
8.6 KiB
Kotlin
232 wiersze
8.6 KiB
Kotlin
/**
|
|
* 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)
|