1 /*
<lambda>null2 * Copyright 2021 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 * https://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 @file:Suppress("DEPRECATION")
18
19 package com.google.accompanist.swiperefresh
20
21 import androidx.compose.animation.core.Animatable
22 import androidx.compose.foundation.MutatePriority
23 import androidx.compose.foundation.MutatorMutex
24 import androidx.compose.foundation.layout.Box
25 import androidx.compose.foundation.layout.PaddingValues
26 import androidx.compose.foundation.layout.padding
27 import androidx.compose.runtime.Composable
28 import androidx.compose.runtime.LaunchedEffect
29 import androidx.compose.runtime.Stable
30 import androidx.compose.runtime.getValue
31 import androidx.compose.runtime.mutableStateOf
32 import androidx.compose.runtime.remember
33 import androidx.compose.runtime.rememberCoroutineScope
34 import androidx.compose.runtime.rememberUpdatedState
35 import androidx.compose.runtime.setValue
36 import androidx.compose.ui.Alignment
37 import androidx.compose.ui.Modifier
38 import androidx.compose.ui.draw.clipToBounds
39 import androidx.compose.ui.geometry.Offset
40 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
41 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
42 import androidx.compose.ui.input.nestedscroll.nestedScroll
43 import androidx.compose.ui.platform.LocalDensity
44 import androidx.compose.ui.unit.Dp
45 import androidx.compose.ui.unit.Velocity
46 import androidx.compose.ui.unit.dp
47 import kotlinx.coroutines.CoroutineScope
48 import kotlinx.coroutines.launch
49 import kotlin.math.absoluteValue
50 import kotlin.math.roundToInt
51
52 private const val DragMultiplier = 0.5f
53
54 /**
55 * Creates a [SwipeRefreshState] that is remembered across compositions.
56 *
57 * Changes to [isRefreshing] will result in the [SwipeRefreshState] being updated.
58 *
59 * @param isRefreshing the value for [SwipeRefreshState.isRefreshing]
60 */
61 @Deprecated(
62 """
63 accompanist/swiperefresh is deprecated.
64 The androidx.compose equivalent of rememberSwipeRefreshState() is rememberPullRefreshState().
65 For more migration information, please visit https://google.github.io/accompanist/swiperefresh/#migration
66 """,
67 replaceWith = ReplaceWith(
68 "rememberPullRefreshState(isRefreshing, onRefresh = )",
69 "androidx.compose.material.pullrefresh.rememberPullRefreshState"
70 )
71 )
72 @Composable
73 public fun rememberSwipeRefreshState(
74 isRefreshing: Boolean
75 ): SwipeRefreshState {
76 return remember {
77 SwipeRefreshState(
78 isRefreshing = isRefreshing
79 )
80 }.apply {
81 this.isRefreshing = isRefreshing
82 }
83 }
84
85 /**
86 * A state object that can be hoisted to control and observe changes for [SwipeRefresh].
87 *
88 * In most cases, this will be created via [rememberSwipeRefreshState].
89 *
90 * @param isRefreshing the initial value for [SwipeRefreshState.isRefreshing]
91 */
92 @Deprecated(
93 """
94 accompanist/swiperefresh is deprecated.
95 The androidx.compose equivalent of SwipeRefreshState is PullRefreshState.
96 For more migration information, please visit https://google.github.io/accompanist/swiperefresh/#migration
97 """
98 )
99 @Stable
100 public class SwipeRefreshState(
101 isRefreshing: Boolean,
102 ) {
103 private val _indicatorOffset = Animatable(0f)
104 private val mutatorMutex = MutatorMutex()
105
106 /**
107 * Whether this [SwipeRefreshState] is currently refreshing or not.
108 */
109 public var isRefreshing: Boolean by mutableStateOf(isRefreshing)
110
111 /**
112 * Whether a swipe/drag is currently in progress.
113 */
114 public var isSwipeInProgress: Boolean by mutableStateOf(false)
115 internal set
116
117 /**
118 * The current offset for the indicator, in pixels.
119 */
120 public val indicatorOffset: Float get() = _indicatorOffset.value
121
animateOffsetTonull122 internal suspend fun animateOffsetTo(offset: Float) {
123 mutatorMutex.mutate {
124 _indicatorOffset.animateTo(offset)
125 }
126 }
127
128 /**
129 * Dispatch scroll delta in pixels from touch events.
130 */
dispatchScrollDeltanull131 internal suspend fun dispatchScrollDelta(delta: Float) {
132 mutatorMutex.mutate(MutatePriority.UserInput) {
133 _indicatorOffset.snapTo(_indicatorOffset.value + delta)
134 }
135 }
136 }
137
138 private class SwipeRefreshNestedScrollConnection(
139 private val state: SwipeRefreshState,
140 private val coroutineScope: CoroutineScope,
141 private val onRefresh: () -> Unit,
142 ) : NestedScrollConnection {
143 var enabled: Boolean = false
144 var refreshTrigger: Float = 0f
145
onPreScrollnull146 override fun onPreScroll(
147 available: Offset,
148 source: NestedScrollSource
149 ): Offset = when {
150 // If swiping isn't enabled, return zero
151 !enabled -> Offset.Zero
152 // If we're refreshing, return zero
153 state.isRefreshing -> Offset.Zero
154 // If the user is swiping up, handle it
155 source == NestedScrollSource.Drag && available.y < 0 -> onScroll(available)
156 else -> Offset.Zero
157 }
158
onPostScrollnull159 override fun onPostScroll(
160 consumed: Offset,
161 available: Offset,
162 source: NestedScrollSource
163 ): Offset = when {
164 // If swiping isn't enabled, return zero
165 !enabled -> Offset.Zero
166 // If we're refreshing, return zero
167 state.isRefreshing -> Offset.Zero
168 // If the user is swiping down and there's y remaining, handle it
169 source == NestedScrollSource.Drag && available.y > 0 -> onScroll(available)
170 else -> Offset.Zero
171 }
172
onScrollnull173 private fun onScroll(available: Offset): Offset {
174 if (available.y > 0) {
175 state.isSwipeInProgress = true
176 } else if (state.indicatorOffset.roundToInt() == 0) {
177 state.isSwipeInProgress = false
178 }
179
180 val newOffset = (available.y * DragMultiplier + state.indicatorOffset).coerceAtLeast(0f)
181 val dragConsumed = newOffset - state.indicatorOffset
182
183 return if (dragConsumed.absoluteValue >= 0.5f) {
184 coroutineScope.launch {
185 state.dispatchScrollDelta(dragConsumed)
186 }
187 // Return the consumed Y
188 Offset(x = 0f, y = dragConsumed / DragMultiplier)
189 } else {
190 Offset.Zero
191 }
192 }
193
onPreFlingnull194 override suspend fun onPreFling(available: Velocity): Velocity {
195 // If we're dragging, not currently refreshing and scrolled
196 // past the trigger point, refresh!
197 if (!state.isRefreshing && state.indicatorOffset >= refreshTrigger) {
198 onRefresh()
199 }
200
201 // Reset the drag in progress state
202 state.isSwipeInProgress = false
203
204 // Don't consume any velocity, to allow the scrolling layout to fling
205 return Velocity.Zero
206 }
207 }
208
209 /**
210 * A layout which implements the swipe-to-refresh pattern, allowing the user to refresh content via
211 * a vertical swipe gesture.
212 *
213 * This layout requires its content to be scrollable so that it receives vertical swipe events.
214 * The scrollable content does not need to be a direct descendant though. Layouts such as
215 * [androidx.compose.foundation.lazy.LazyColumn] are automatically scrollable, but others such as
216 * [androidx.compose.foundation.layout.Column] require you to provide the
217 * [androidx.compose.foundation.verticalScroll] modifier to that content.
218 *
219 * Apps should provide a [onRefresh] block to be notified each time a swipe to refresh gesture
220 * is completed. That block is responsible for updating the [state] as appropriately,
221 * typically by setting [SwipeRefreshState.isRefreshing] to `true` once a 'refresh' has been
222 * started. Once a refresh has completed, the app should then set
223 * [SwipeRefreshState.isRefreshing] to `false`.
224 *
225 * If an app wishes to show the progress animation outside of a swipe gesture, it can
226 * set [SwipeRefreshState.isRefreshing] as required.
227 *
228 * This layout does not clip any of it's contents, including the indicator. If clipping
229 * is required, apps can provide the [androidx.compose.ui.draw.clipToBounds] modifier.
230 *
231 * @sample com.google.accompanist.sample.swiperefresh.SwipeRefreshSample
232 *
233 * @param state the state object to be used to control or observe the [SwipeRefresh] state.
234 * @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
235 * @param modifier the modifier to apply to this layout.
236 * @param swipeEnabled Whether the the layout should react to swipe gestures or not.
237 * @param refreshTriggerDistance The minimum swipe distance which would trigger a refresh.
238 * @param indicatorAlignment The alignment of the indicator. Defaults to [Alignment.TopCenter].
239 * @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
240 * @param indicator the indicator that represents the current state. By default this
241 * will use a [SwipeRefreshIndicator].
242 * @param clipIndicatorToPadding Whether to clip the indicator to [indicatorPadding]. If false is
243 * provided the indicator will be clipped to the [content] bounds. Defaults to true.
244 * @param content The content containing a scroll composable.
245 */
246 @Deprecated(
247 """
248 accompanist/swiperefresh is deprecated.
249 The androidx.compose equivalent of SwipeRefresh is Modifier.pullRefresh().
250 This is often migrated as:
251 Box(modifier = Modifier.pullRefresh(refreshState)) {
252 ...
253 PullRefreshIndicator(...)
254 }
255
256 For more migration information, please visit https://google.github.io/accompanist/swiperefresh/#migration
257 """
258 )
259 @Composable
SwipeRefreshnull260 public fun SwipeRefresh(
261 state: SwipeRefreshState,
262 onRefresh: () -> Unit,
263 modifier: Modifier = Modifier,
264 swipeEnabled: Boolean = true,
265 refreshTriggerDistance: Dp = 80.dp,
266 indicatorAlignment: Alignment = Alignment.TopCenter,
267 indicatorPadding: PaddingValues = PaddingValues(0.dp),
268 indicator: @Composable (state: SwipeRefreshState, refreshTrigger: Dp) -> Unit = { s, trigger ->
269 SwipeRefreshIndicator(s, trigger)
270 },
271 clipIndicatorToPadding: Boolean = true,
272 content: @Composable () -> Unit,
273 ) {
274 val coroutineScope = rememberCoroutineScope()
275 val updatedOnRefresh = rememberUpdatedState(onRefresh)
276
277 // Our LaunchedEffect, which animates the indicator to its resting position
<lambda>null278 LaunchedEffect(state.isSwipeInProgress) {
279 if (!state.isSwipeInProgress) {
280 // If there's not a swipe in progress, rest the indicator at 0f
281 state.animateOffsetTo(0f)
282 }
283 }
284
<lambda>null285 val refreshTriggerPx = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
286
287 // Our nested scroll connection, which updates our state.
<lambda>null288 val nestedScrollConnection = remember(state, coroutineScope) {
289 SwipeRefreshNestedScrollConnection(state, coroutineScope) {
290 // On refresh, re-dispatch to the update onRefresh block
291 updatedOnRefresh.value.invoke()
292 }
293 }.apply {
294 this.enabled = swipeEnabled
295 this.refreshTrigger = refreshTriggerPx
296 }
297
<lambda>null298 Box(modifier.nestedScroll(connection = nestedScrollConnection)) {
299 content()
300
301 Box(
302 Modifier
303 // If we're not clipping to the padding, we use clipToBounds() before the padding()
304 // modifier.
305 .let { if (!clipIndicatorToPadding) it.clipToBounds() else it }
306 .padding(indicatorPadding)
307 .matchParentSize()
308 // Else, if we're are clipping to the padding, we use clipToBounds() after
309 // the padding() modifier.
310 .let { if (clipIndicatorToPadding) it.clipToBounds() else it }
311 ) {
312 Box(Modifier.align(indicatorAlignment)) {
313 indicator(state, refreshTriggerDistance)
314 }
315 }
316 }
317 }
318