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