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