• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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