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)