PullRefreshState.kt (8543B) - 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.core.animate
     20 import androidx.compose.foundation.MutatorMutex
     21 import androidx.compose.runtime.*
     22 import androidx.compose.ui.platform.LocalDensity
     23 import androidx.compose.ui.unit.Dp
     24 import androidx.compose.ui.unit.dp
     25 import kotlinx.coroutines.CoroutineScope
     26 import kotlinx.coroutines.launch
     27 import kotlin.math.abs
     28 import kotlin.math.pow
     29 
     30 /**
     31  * Creates a [PullRefreshState] that is remembered across compositions.
     32  *
     33  * Changes to [refreshing] will result in [PullRefreshState] being updated.
     34  *
     35  * @sample androidx.compose.material.samples.PullRefreshSample
     36  *
     37  * @param refreshing A boolean representing whether a refresh is currently occurring.
     38  * @param onRefresh The function to be called to trigger a refresh.
     39  * @param refreshThreshold The threshold below which, if a release
     40  * occurs, [onRefresh] will be called.
     41  * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
     42  * offset corresponds to the position of the bottom of the indicator.
     43  */
     44 @Composable
     45 fun rememberPullRefreshState(
     46     refreshing: Boolean,
     47     onRefresh: () -> Unit,
     48     refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
     49     refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
     50 ): PullRefreshState {
     51     require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
     52 
     53     val scope = rememberCoroutineScope()
     54     val onRefreshState = rememberUpdatedState(onRefresh)
     55     val thresholdPx: Float
     56     val refreshingOffsetPx: Float
     57 
     58     with(LocalDensity.current) {
     59         thresholdPx = refreshThreshold.toPx()
     60         refreshingOffsetPx = refreshingOffset.toPx()
     61     }
     62 
     63     val state = remember(scope) {
     64         PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx)
     65     }
     66 
     67     SideEffect {
     68         state.setRefreshing(refreshing)
     69         state.setThreshold(thresholdPx)
     70         state.setRefreshingOffset(refreshingOffsetPx)
     71     }
     72 
     73     return state
     74 }
     75 
     76 /**
     77  * A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh
     78  * behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
     79  *
     80  * Provides [progress], a float representing how far the user has pulled as a percentage of the
     81  * refreshThreshold. Values of one or less indicate that the user has not yet pulled past the
     82  * threshold. Values greater than one indicate how far past the threshold the user has pulled.
     83  *
     84  * Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like
     85  * pull-to-refresh behaviour with a custom indicator.
     86  *
     87  * Should be created using [rememberPullRefreshState].
     88  */
     89 class PullRefreshState internal constructor(
     90     private val animationScope: CoroutineScope,
     91     private val onRefreshState: State<() -> Unit>,
     92     refreshingOffset: Float,
     93     threshold: Float,
     94 ) {
     95     /**
     96      * A float representing how far the user has pulled as a percentage of the refreshThreshold.
     97      *
     98      * If the component has not been pulled at all, progress is zero. If the pull has reached
     99      * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has
    100      * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to
    101      * two times the refreshThreshold.
    102      */
    103     val progress get() = adjustedDistancePulled / threshold
    104 
    105     internal val refreshing get() = _refreshing
    106     internal val position get() = _position
    107     internal val threshold get() = _threshold
    108 
    109     private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier }
    110 
    111     private var _refreshing by mutableStateOf(false)
    112     private var _position by mutableFloatStateOf(0f)
    113     private var distancePulled by mutableFloatStateOf(0f)
    114     private var _threshold by mutableFloatStateOf(threshold)
    115     private var _refreshingOffset by mutableFloatStateOf(refreshingOffset)
    116 
    117     internal fun onPull(pullDelta: Float): Float {
    118         if (_refreshing) return 0f // Already refreshing, do nothing.
    119 
    120         val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
    121         val dragConsumed = newOffset - distancePulled
    122         distancePulled = newOffset
    123         _position = calculateIndicatorPosition()
    124         return dragConsumed
    125     }
    126 
    127     internal fun onRelease(velocity: Float): Float {
    128         if (refreshing) return 0f // Already refreshing, do nothing
    129 
    130         if (adjustedDistancePulled > threshold) {
    131             onRefreshState.value()
    132         }
    133         animateIndicatorTo(0f)
    134         val consumed = when {
    135             // We are flinging without having dragged the pull refresh (for example a fling inside
    136             // a list) - don't consume
    137             distancePulled == 0f -> 0f
    138             // If the velocity is negative, the fling is upwards, and we don't want to prevent the
    139             // the list from scrolling
    140             velocity < 0f -> 0f
    141             // We are showing the indicator, and the fling is downwards - consume everything
    142             else -> velocity
    143         }
    144         distancePulled = 0f
    145         return consumed
    146     }
    147 
    148     internal fun setRefreshing(refreshing: Boolean) {
    149         if (_refreshing != refreshing) {
    150             _refreshing = refreshing
    151             distancePulled = 0f
    152             animateIndicatorTo(if (refreshing) _refreshingOffset else 0f)
    153         }
    154     }
    155 
    156     internal fun setThreshold(threshold: Float) {
    157         _threshold = threshold
    158     }
    159 
    160     internal fun setRefreshingOffset(refreshingOffset: Float) {
    161         if (_refreshingOffset != refreshingOffset) {
    162             _refreshingOffset = refreshingOffset
    163             if (refreshing) animateIndicatorTo(refreshingOffset)
    164         }
    165     }
    166 
    167     // Make sure to cancel any existing animations when we launch a new one. We use this instead of
    168     // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
    169     // overhead of running through the animation pipeline instead of directly mutating the state.
    170     private val mutatorMutex = MutatorMutex()
    171 
    172     private fun animateIndicatorTo(offset: Float) = animationScope.launch {
    173         mutatorMutex.mutate {
    174             animate(initialValue = _position, targetValue = offset) { value, _ ->
    175                 _position = value
    176             }
    177         }
    178     }
    179 
    180     private fun calculateIndicatorPosition(): Float = when {
    181         // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
    182         adjustedDistancePulled <= threshold -> adjustedDistancePulled
    183         else -> {
    184             // How far beyond the threshold pull has gone, as a percentage of the threshold.
    185             val overshootPercent = abs(progress) - 1.0f
    186             // Limit the overshoot to 200%. Linear between 0 and 200.
    187             val linearTension = overshootPercent.coerceIn(0f, 2f)
    188             // Non-linear tension. Increases with linearTension, but at a decreasing rate.
    189             val tensionPercent = linearTension - linearTension.pow(2) / 4
    190             // The additional offset beyond the threshold.
    191             val extraOffset = threshold * tensionPercent
    192             threshold + extraOffset
    193         }
    194     }
    195 }
    196 
    197 /**
    198  * Default parameter values for [rememberPullRefreshState].
    199  */
    200 object PullRefreshDefaults {
    201     /**
    202      * If the indicator is below this threshold offset when it is released, a refresh
    203      * will be triggered.
    204      */
    205     val RefreshThreshold = 80.dp
    206 
    207     /**
    208      * The offset at which the indicator should be rendered whilst a refresh is occurring.
    209      */
    210     val RefreshingOffset = 56.dp
    211 }
    212 
    213 /**
    214  * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which
    215  * is used in calculating the indicator position (when the adjusted distance pulled is less than
    216  * the refresh threshold, it is the indicator position, otherwise the indicator position is
    217  * derived from the progress).
    218  */
    219 private const val DragMultiplier = 0.5f