PullRefreshIndicator.kt (8506B) - raw
1 /* 2 * Copyright 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package me.rhunk.snapenhance.ui.util.pullrefresh 18 19 import androidx.compose.animation.Crossfade 20 import androidx.compose.animation.core.LinearEasing 21 import androidx.compose.animation.core.animateFloatAsState 22 import androidx.compose.animation.core.tween 23 import androidx.compose.foundation.Canvas 24 import androidx.compose.foundation.layout.Box 25 import androidx.compose.foundation.layout.fillMaxSize 26 import androidx.compose.foundation.layout.size 27 import androidx.compose.foundation.shape.CircleShape 28 import androidx.compose.material3.CircularProgressIndicator 29 import androidx.compose.material3.MaterialTheme 30 import androidx.compose.material3.Surface 31 import androidx.compose.material3.contentColorFor 32 import androidx.compose.runtime.Composable 33 import androidx.compose.runtime.Immutable 34 import androidx.compose.runtime.derivedStateOf 35 import androidx.compose.runtime.getValue 36 import androidx.compose.runtime.remember 37 import androidx.compose.ui.Alignment 38 import androidx.compose.ui.Modifier 39 import androidx.compose.ui.geometry.Offset 40 import androidx.compose.ui.geometry.Rect 41 import androidx.compose.ui.geometry.center 42 import androidx.compose.ui.graphics.Color 43 import androidx.compose.ui.graphics.Path 44 import androidx.compose.ui.graphics.PathFillType 45 import androidx.compose.ui.graphics.StrokeCap 46 import androidx.compose.ui.graphics.drawscope.DrawScope 47 import androidx.compose.ui.graphics.drawscope.Stroke 48 import androidx.compose.ui.graphics.drawscope.rotate 49 import androidx.compose.ui.semantics.semantics 50 import androidx.compose.ui.unit.dp 51 import kotlin.math.abs 52 import kotlin.math.max 53 import kotlin.math.min 54 import kotlin.math.pow 55 56 /** 57 * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. 58 * 59 * @sample androidx.compose.material.samples.PullRefreshSample 60 * 61 * @param refreshing A boolean representing whether a refresh is occurring. 62 * @param state The [PullRefreshState] which controls where and how the indicator will be drawn. 63 * @param modifier Modifiers for the indicator. 64 * @param backgroundColor The color of the indicator's background. 65 * @param contentColor The color of the indicator's arc and arrow. 66 * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. 67 */ 68 // TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to 69 // enable people to use this indicator with custom pull-to-refresh components. 70 @Composable 71 fun PullRefreshIndicator( 72 refreshing: Boolean, 73 state: PullRefreshState, 74 modifier: Modifier = Modifier, 75 backgroundColor: Color = MaterialTheme.colorScheme.surface, 76 contentColor: Color = contentColorFor(backgroundColor), 77 scale: Boolean = false, 78 ) { 79 val showElevation by remember(refreshing, state) { 80 derivedStateOf { refreshing || state.position > 0.5f } 81 } 82 83 Surface( 84 modifier = modifier 85 .size(IndicatorSize) 86 .pullRefreshIndicatorTransform(state, scale), 87 shape = SpinnerShape, 88 color = backgroundColor, 89 shadowElevation = if (showElevation) Elevation else 0.dp, 90 ) { 91 Crossfade( 92 targetState = refreshing, 93 animationSpec = tween(durationMillis = CrossfadeDurationMs), 94 ) { refreshing -> 95 Box( 96 modifier = Modifier.fillMaxSize(), 97 contentAlignment = Alignment.Center, 98 ) { 99 val spinnerSize = (ArcRadius + StrokeWidth).times(2) 100 101 if (refreshing) { 102 CircularProgressIndicator( 103 color = contentColor, 104 strokeWidth = StrokeWidth, 105 modifier = Modifier.size(spinnerSize), 106 ) 107 } else { 108 CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) 109 } 110 } 111 } 112 } 113 } 114 115 /** 116 * Modifier.size MUST be specified. 117 */ 118 @Composable 119 private fun CircularArrowIndicator( 120 state: PullRefreshState, 121 color: Color, 122 modifier: Modifier, 123 ) { 124 val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } 125 126 val targetAlpha by remember(state) { 127 derivedStateOf { 128 if (state.progress >= 1f) MaxAlpha else MinAlpha 129 } 130 } 131 132 val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) 133 134 // Empty semantics for tests 135 Canvas(modifier.semantics {}) { 136 val values = ArrowValues(state.progress) 137 val alpha = alphaState.value 138 139 rotate(degrees = values.rotation) { 140 val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f 141 val arcBounds = Rect( 142 size.center.x - arcRadius, 143 size.center.y - arcRadius, 144 size.center.x + arcRadius, 145 size.center.y + arcRadius, 146 ) 147 drawArc( 148 color = color, 149 alpha = alpha, 150 startAngle = values.startAngle, 151 sweepAngle = values.endAngle - values.startAngle, 152 useCenter = false, 153 topLeft = arcBounds.topLeft, 154 size = arcBounds.size, 155 style = Stroke( 156 width = StrokeWidth.toPx(), 157 cap = StrokeCap.Square, 158 ), 159 ) 160 drawArrow(path, arcBounds, color, alpha, values) 161 } 162 } 163 } 164 165 @Immutable 166 private class ArrowValues( 167 val rotation: Float, 168 val startAngle: Float, 169 val endAngle: Float, 170 val scale: Float, 171 ) 172 173 private fun ArrowValues(progress: Float): ArrowValues { 174 // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. 175 val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 176 // How far beyond the threshold pull has gone, as a percentage of the threshold. 177 val overshootPercent = abs(progress) - 1.0f 178 // Limit the overshoot to 200%. Linear between 0 and 200. 179 val linearTension = overshootPercent.coerceIn(0f, 2f) 180 // Non-linear tension. Increases with linearTension, but at a decreasing rate. 181 val tensionPercent = linearTension - linearTension.pow(2) / 4 182 183 // Calculations based on SwipeRefreshLayout specification. 184 val endTrim = adjustedPercent * MaxProgressArc 185 val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f 186 val startAngle = rotation * 360 187 val endAngle = (rotation + endTrim) * 360 188 val scale = min(1f, adjustedPercent) 189 190 return ArrowValues(rotation, startAngle, endAngle, scale) 191 } 192 193 private fun DrawScope.drawArrow( 194 arrow: Path, 195 bounds: Rect, 196 color: Color, 197 alpha: Float, 198 values: ArrowValues, 199 ) { 200 arrow.reset() 201 arrow.moveTo(0f, 0f) // Move to left corner 202 arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner 203 204 // Line to tip of arrow 205 arrow.lineTo( 206 x = ArrowWidth.toPx() * values.scale / 2, 207 y = ArrowHeight.toPx() * values.scale, 208 ) 209 210 val radius = min(bounds.width, bounds.height) / 2f 211 val inset = ArrowWidth.toPx() * values.scale / 2f 212 arrow.translate( 213 Offset( 214 x = radius + bounds.center.x - inset, 215 y = bounds.center.y + StrokeWidth.toPx() / 2f, 216 ), 217 ) 218 arrow.close() 219 rotate(degrees = values.endAngle) { 220 drawPath(path = arrow, color = color, alpha = alpha) 221 } 222 } 223 224 private const val CrossfadeDurationMs = 100 225 private const val MaxProgressArc = 0.8f 226 227 private val IndicatorSize = 40.dp 228 private val SpinnerShape = CircleShape 229 private val ArcRadius = 7.5.dp 230 private val StrokeWidth = 2.5.dp 231 private val ArrowWidth = 10.dp 232 private val ArrowHeight = 5.dp 233 private val Elevation = 6.dp 234 235 // Values taken from SwipeRefreshLayout 236 private const val MinAlpha = 0.3f 237 private const val MaxAlpha = 1f 238 private val AlphaTween = tween<Float>(300, easing = LinearEasing)