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