-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
48 changed files
with
1,720 additions
and
1,423 deletions.
There are no files selected for viewing
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
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
120 changes: 120 additions & 0 deletions
120
app/src/main/java/androidx/compose/material3/pullrefresh/PullRefresh.kt
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
/* | ||
* Copyright 2022 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.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: | ||
* | ||
* @sample androidx.compose.material.samples.PullRefreshSample | ||
* | ||
* @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. | ||
*/ | ||
// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple. | ||
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: | ||
* | ||
* @sample androidx.compose.material.samples.CustomPullRefreshSample | ||
* | ||
* @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. | ||
*/ | ||
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 { | ||
return Velocity(0f, onRelease(available.y)) | ||
} | ||
} |
242 changes: 242 additions & 0 deletions
242
app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,242 @@ | ||
/* | ||
* Copyright 2022 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.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.CircularProgressIndicator | ||
import androidx.compose.material3.MaterialTheme | ||
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.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.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. | ||
* | ||
* @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. | ||
*/ | ||
// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to | ||
// enable people to use this indicator with custom pull-to-refresh components. | ||
@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 } | ||
} | ||
|
||
Box( | ||
modifier = modifier | ||
.size(IndicatorSize) | ||
.pullRefreshIndicatorTransform(state, scale) | ||
.shadow(if (showElevation) Elevation else 0.dp, SpinnerShape, clip = true) | ||
.background( | ||
color = surfaceColorAtElevation( | ||
color = backgroundColor, elevation = Elevation | ||
), shape = SpinnerShape | ||
), | ||
) { | ||
Crossfade( | ||
targetState = refreshing, | ||
animationSpec = tween(durationMillis = CrossfadeDurationMs), | ||
) { 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) 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) |
13 changes: 13 additions & 0 deletions
13
app/src/main/java/androidx/compose/material3/pullrefresh/PullRefreshIndicatorPatch.kt
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package androidx.compose.material3.pullrefresh | ||
|
||
import androidx.compose.material3.MaterialTheme | ||
import androidx.compose.material3.surfaceColorAtElevation | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.ui.graphics.Color | ||
import androidx.compose.ui.unit.Dp | ||
|
||
@Composable | ||
internal fun surfaceColorAtElevation(color: Color, elevation: Dp): Color = when (color) { | ||
MaterialTheme.colorScheme.surface -> MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) | ||
else -> color | ||
} |
Oops, something went wrong.