/
SwipeRefresh.kt
284 lines (259 loc) · 10.6 KB
/
SwipeRefresh.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.accompanist.swiperefresh
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
private const val DragMultiplier = 0.5f
/**
* Creates a [SwipeRefreshState] that is remembered across compositions.
*
* Changes to [isRefreshing] will result in the [SwipeRefreshState] being updated.
*
* @param isRefreshing the value for [SwipeRefreshState.isRefreshing]
*/
@Composable
fun rememberSwipeRefreshState(
isRefreshing: Boolean
): SwipeRefreshState {
return remember {
SwipeRefreshState(
isRefreshing = isRefreshing
)
}.apply {
this.isRefreshing = isRefreshing
}
}
/**
* A state object that can be hoisted to control and observe changes for [SwipeRefresh].
*
* In most cases, this will be created via [rememberSwipeRefreshState].
*
* @param isRefreshing the initial value for [SwipeRefreshState.isRefreshing]
*/
@Stable
class SwipeRefreshState(
isRefreshing: Boolean,
) {
private val _indicatorOffset = Animatable(0f)
private val mutatorMutex = MutatorMutex()
/**
* Whether this [SwipeRefreshState] is currently refreshing or not.
*/
var isRefreshing: Boolean by mutableStateOf(isRefreshing)
/**
* Whether a swipe/drag is currently in progress.
*/
var isSwipeInProgress: Boolean by mutableStateOf(false)
internal set
/**
* The current offset for the indicator, in pixels.
*/
val indicatorOffset: Float get() = _indicatorOffset.value
internal suspend fun animateOffsetTo(offset: Float) {
mutatorMutex.mutate {
_indicatorOffset.animateTo(offset)
}
}
/**
* Dispatch scroll delta in pixels from touch events.
*/
internal suspend fun dispatchScrollDelta(delta: Float) {
mutatorMutex.mutate(MutatePriority.UserInput) {
_indicatorOffset.snapTo(_indicatorOffset.value + delta)
}
}
}
private class SwipeRefreshNestedScrollConnection(
private val state: SwipeRefreshState,
private val coroutineScope: CoroutineScope,
private val onRefresh: () -> Unit,
) : NestedScrollConnection {
var enabled: Boolean = false
var refreshTrigger: Float = 0f
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = when {
// If swiping isn't enabled, return zero
!enabled -> Offset.Zero
// If we're refreshing, return zero
state.isRefreshing -> Offset.Zero
// If the user is swiping up, handle it
source == NestedScrollSource.Drag && available.y < 0 -> onScroll(available)
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = when {
// If swiping isn't enabled, return zero
!enabled -> Offset.Zero
// If we're refreshing, return zero
state.isRefreshing -> Offset.Zero
// If the user is swiping down and there's y remaining, handle it
source == NestedScrollSource.Drag && available.y > 0 -> onScroll(available)
else -> Offset.Zero
}
private fun onScroll(available: Offset): Offset {
if (available.y > 0) {
state.isSwipeInProgress = true
} else if (state.indicatorOffset.roundToInt() == 0) {
state.isSwipeInProgress = false
}
val newOffset = (available.y * DragMultiplier + state.indicatorOffset).coerceAtLeast(0f)
val dragConsumed = newOffset - state.indicatorOffset
return if (dragConsumed.absoluteValue >= 0.5f) {
coroutineScope.launch {
state.dispatchScrollDelta(dragConsumed)
}
// Return the consumed Y
Offset(x = 0f, y = dragConsumed / DragMultiplier)
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
// If we're dragging, not currently refreshing and scrolled
// past the trigger point, refresh!
if (!state.isRefreshing && state.indicatorOffset >= refreshTrigger) {
onRefresh()
}
// Reset the drag in progress state
state.isSwipeInProgress = false
// Don't consume any velocity, to allow the scrolling layout to fling
return Velocity.Zero
}
}
/**
* A layout which implements the swipe-to-refresh pattern, allowing the user to refresh content via
* a vertical swipe gesture.
*
* This layout requires its content to be scrollable so that it receives vertical swipe events.
* The scrollable content does not need to be a direct descendant though. Layouts such as
* [androidx.compose.foundation.lazy.LazyColumn] are automatically scrollable, but others such as
* [androidx.compose.foundation.layout.Column] require you to provide the
* [androidx.compose.foundation.verticalScroll] modifier to that content.
*
* Apps should provide a [onRefresh] block to be notified each time a swipe to refresh gesture
* is completed. That block is responsible for updating the [state] as appropriately,
* typically by setting [SwipeRefreshState.isRefreshing] to `true` once a 'refresh' has been
* started. Once a refresh has completed, the app should then set
* [SwipeRefreshState.isRefreshing] to `false`.
*
* If an app wishes to show the progress animation outside of a swipe gesture, it can
* set [SwipeRefreshState.isRefreshing] as required.
*
* This layout does not clip any of it's contents, including the indicator. If clipping
* is required, apps can provide the [androidx.compose.ui.draw.clipToBounds] modifier.
*
* @sample com.google.accompanist.sample.swiperefresh.SwipeRefreshSample
*
* @param state the state object to be used to control or observe the [SwipeRefresh] state.
* @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
* @param modifier the modifier to apply to this layout.
* @param swipeEnabled Whether the the layout should react to swipe gestures or not.
* @param refreshTriggerDistance The minimum swipe distance which would trigger a refresh.
* @param indicatorAlignment The alignment of the indicator. Defaults to [Alignment.TopCenter].
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
* @param indicator the indicator that represents the current state. By default this
* will use a [SwipeRefreshIndicator].
* @param clipIndicatorToPadding Whether to clip the indicator to [indicatorPadding]. If false is
* provided the indicator will be clipped to the [content] bounds. Defaults to true.
* @param content The content containing a scroll composable.
*/
@Composable
fun SwipeRefresh(
state: SwipeRefreshState,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
swipeEnabled: Boolean = true,
refreshTriggerDistance: Dp = 80.dp,
indicatorAlignment: Alignment = Alignment.TopCenter,
indicatorPadding: PaddingValues = PaddingValues(0.dp),
indicator: @Composable (state: SwipeRefreshState, refreshTrigger: Dp) -> Unit = { s, trigger ->
SwipeRefreshIndicator(s, trigger)
},
clipIndicatorToPadding: Boolean = true,
content: @Composable () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val updatedOnRefresh = rememberUpdatedState(onRefresh)
// Our LaunchedEffect, which animates the indicator to its resting position
LaunchedEffect(state.isSwipeInProgress) {
if (!state.isSwipeInProgress) {
// If there's not a swipe in progress, rest the indicator at 0f
state.animateOffsetTo(0f)
}
}
val refreshTriggerPx = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
// Our nested scroll connection, which updates our state.
val nestedScrollConnection = remember(state, coroutineScope) {
SwipeRefreshNestedScrollConnection(state, coroutineScope) {
// On refresh, re-dispatch to the update onRefresh block
updatedOnRefresh.value.invoke()
}
}.apply {
this.enabled = swipeEnabled
this.refreshTrigger = refreshTriggerPx
}
Box(modifier.nestedScroll(connection = nestedScrollConnection)) {
content()
Box(
Modifier
// If we're not clipping to the padding, we use clipToBounds() before the padding()
// modifier.
.let { if (!clipIndicatorToPadding) it.clipToBounds() else it }
.padding(indicatorPadding)
.matchParentSize()
// Else, if we're are clipping to the padding, we use clipToBounds() after
// the padding() modifier.
.let { if (clipIndicatorToPadding) it.clipToBounds() else it }
) {
Box(Modifier.align(indicatorAlignment)) {
indicator(state, refreshTriggerDistance)
}
}
}
}