[go: nahoru, domu]

blob: 9cafffbcae6e987c3100ce31ff38549b4974b9fd [file] [log] [blame]
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.pullrefresh
import androidx.annotation.FloatRange
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.background
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.CircularIndicatorDiameter
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.pullrefresh.PullRefreshDefaults.Indicator
import androidx.compose.material3.tokens.MotionTokens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
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.shadow
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.Shape
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.graphics.graphicsLayer
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
// TODO: Provide samples once PullRefreshState implementation lands
/**
* Material Design pull refresh indicator
*
* @param state the state of this [PullRefreshContainer]
* @param modifier the [Modifier] to be applied to this container
* @param indicator The indicator placed inside of the [PullRefreshContainer]. Has access to [state]
* @param shape the [Shape] of this container
* @param containerColor the color of this container
* @param contentColor the color of the progress indicator
*/
@Composable
@ExperimentalMaterial3Api
@Suppress("ComposableLambdaParameterPosition")
internal fun PullRefreshContainer(
state: PullRefreshState,
modifier: Modifier = Modifier,
indicator: @Composable (PullRefreshState) -> Unit = { pullRefreshState ->
Indicator(state = pullRefreshState)
},
shape: Shape = PullRefreshDefaults.shape,
containerColor: Color = PullRefreshDefaults.containerColor,
contentColor: Color = contentColorFor(backgroundColor = containerColor),
) {
// Surface is not used here, as we do not want its input-blocking behaviour, since the indicator
// is typically displayed above other (possibly) interactive indicator.
val showElevation = remember {
derivedStateOf { state.refreshing || state.verticalOffset > 0.5f }
}
CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(
modifier = modifier
.size(CircularIndicatorDiameter)
.graphicsLayer {
translationY = state.verticalOffset - size.height
}
.shadow(
// Avoid shadow when indicator is hidden
elevation = if (showElevation.value) Elevation else 0.dp,
shape = shape,
clip = true
)
.background(color = containerColor, shape = shape)
) {
indicator(state)
}
}
}
/**
* Contains the default values for [PullRefreshContainer]
*/
@ExperimentalMaterial3Api
internal object PullRefreshDefaults {
/** The default shape for [PullRefreshContainer] */
val shape: Shape = CircleShape
/** The default container color for [PullRefreshContainer] */
val containerColor: Color @Composable get() = MaterialTheme.colorScheme.surfaceContainerHigh
/**
* The default indicator for [PullRefreshContainer].
*/
@Composable
fun Indicator(
state: PullRefreshState,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
) {
Crossfade(
targetState = state.refreshing,
animationSpec = tween(durationMillis = CrossfadeDurationMs)
) { refreshing ->
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (refreshing) {
CircularProgressIndicator(
strokeWidth = StrokeWidth,
color = color,
modifier = Modifier.size(SpinnerSize),
)
} else {
CircularArrowProgressIndicator(
progress = { state.progress },
color = color,
strokeWidth = StrokeWidth,
)
}
}
}
}
}
/**
* The state that is associated with a [PullRefreshContainer].
* Each instance of [PullRefreshContainer] should have its own [PullRefreshState].
*/
@Stable
@ExperimentalMaterial3Api
internal interface PullRefreshState {
/** The threshold above which, if a release occurs, a refresh will be called */
val positionalThreshold: Dp
/**
* PullRefresh progress towards [positionalThreshold]. 0.0 indicates no progress, 1.0 indicates
* complete progress, > 1.0 indicates overshoot beyond the provided threshold
*/
@get:FloatRange(from = 0.0)
val progress: Float
/**
* Indicates whether a refresh is occurring
*/
val refreshing: Boolean
/**
* The vertical offset (in pixels) for the [PullRefreshContainer] to consume
*/
@get:FloatRange(from = 0.0)
val verticalOffset: Float
}
/** The default pull indicator for [PullRefreshContainer] */
@Composable
private fun CircularArrowProgressIndicator(
progress: () -> Float,
color: Color,
strokeWidth: Dp,
modifier: Modifier = Modifier,
) {
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
// TODO: Consider refactoring this sub-component utilizing Modifier.Node
val targetAlpha by remember {
derivedStateOf { if (progress() >= 1f) MaxAlpha else MinAlpha }
}
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
Canvas(
modifier
.semantics(mergeDescendants = true) {
progressBarRangeInfo =
ProgressBarRangeInfo(progress(), 0f..1f, 0)
}
.size(SpinnerSize)
) {
val values = ArrowValues(progress())
val alpha = alphaState.value
rotate(degrees = values.rotation) {
val arcRadius = ArcRadius.toPx() + strokeWidth.toPx() / 2f
val arcBounds = Rect(center = size.center, radius = arcRadius)
drawCircularIndicator(color, alpha, values, arcBounds, strokeWidth)
drawArrow(path, arcBounds, color, alpha, values, strokeWidth)
}
}
}
private fun DrawScope.drawCircularIndicator(
color: Color,
alpha: Float,
values: ArrowValues,
arcBounds: Rect,
strokeWidth: Dp
) {
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
)
)
}
@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,
strokeWidth: Dp,
) {
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 MaxProgressArc = 0.8f
private const val CrossfadeDurationMs = MotionTokens.DurationShort2.toInt()
/** The default stroke width for [Indicator] */
private val StrokeWidth = 2.5.dp
private val ArcRadius = 7.5.dp
private val SpinnerSize = 20.dp // (ArcRadius + PullRefreshIndicatorDefaults.StrokeWidth).times(2)
private val Elevation = 6.dp
private val ArrowWidth = 10.dp
private val ArrowHeight = 5.dp
// Values taken from SwipeRefreshLayout
private const val MinAlpha = 0.3f
private const val MaxAlpha = 1f
private val AlphaTween = tween<Float>(MotionTokens.DurationMedium2.toInt(), easing = LinearEasing)