• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 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 com.android.compose.pager
18 
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.animation.core.AnimationState
21 import androidx.compose.animation.core.DecayAnimationSpec
22 import androidx.compose.animation.core.animateDecay
23 import androidx.compose.animation.core.animateTo
24 import androidx.compose.animation.core.calculateTargetValue
25 import androidx.compose.animation.core.spring
26 import androidx.compose.animation.rememberSplineBasedDecay
27 import androidx.compose.foundation.gestures.FlingBehavior
28 import androidx.compose.foundation.gestures.ScrollScope
29 import androidx.compose.foundation.lazy.LazyListItemInfo
30 import androidx.compose.foundation.lazy.LazyListState
31 import androidx.compose.runtime.Composable
32 import androidx.compose.runtime.getValue
33 import androidx.compose.runtime.mutableStateOf
34 import androidx.compose.runtime.remember
35 import androidx.compose.runtime.setValue
36 import kotlin.math.abs
37 
38 /** Default values used for [SnappingFlingBehavior] & [rememberSnappingFlingBehavior]. */
39 internal object SnappingFlingBehaviorDefaults {
40     /** TODO */
41     val snapAnimationSpec: AnimationSpec<Float> = spring(stiffness = 600f)
42 }
43 
44 /**
45  * Create and remember a snapping [FlingBehavior] to be used with [LazyListState].
46  *
47  * @param lazyListState The [LazyListState] to update.
48  * @param decayAnimationSpec The decay animation spec to use for decayed flings.
49  * @param snapAnimationSpec The animation spec to use when snapping.
50  *
51  * TODO: move this to a new module and make it public
52  */
53 @Composable
rememberSnappingFlingBehaviornull54 internal fun rememberSnappingFlingBehavior(
55     lazyListState: LazyListState,
56     decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
57     snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
58 ): SnappingFlingBehavior =
59     remember(lazyListState, decayAnimationSpec, snapAnimationSpec) {
60         SnappingFlingBehavior(
61             lazyListState = lazyListState,
62             decayAnimationSpec = decayAnimationSpec,
63             snapAnimationSpec = snapAnimationSpec,
64         )
65     }
66 
67 /**
68  * A snapping [FlingBehavior] for [LazyListState]. Typically this would be created via
69  * [rememberSnappingFlingBehavior].
70  *
71  * @param lazyListState The [LazyListState] to update.
72  * @param decayAnimationSpec The decay animation spec to use for decayed flings.
73  * @param snapAnimationSpec The animation spec to use when snapping.
74  */
75 internal class SnappingFlingBehavior(
76     private val lazyListState: LazyListState,
77     private val decayAnimationSpec: DecayAnimationSpec<Float>,
78     private val snapAnimationSpec: AnimationSpec<Float>,
79 ) : FlingBehavior {
80     /** The target item index for any on-going animations. */
81     var animationTarget: Int? by mutableStateOf(null)
82         private set
83 
performFlingnull84     override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
85         val itemInfo = currentItemInfo ?: return initialVelocity
86 
87         // If the decay fling can scroll past the current item, fling with decay
88         return if (decayAnimationSpec.canFlingPastCurrentItem(itemInfo, initialVelocity)) {
89             performDecayFling(initialVelocity, itemInfo)
90         } else {
91             // Otherwise we 'spring' to current/next item
92             performSpringFling(
93                 index =
94                     when {
95                         // If the velocity is greater than 1 item per second (velocity is px/s),
96                         // spring
97                         // in the relevant direction
98                         initialVelocity > itemInfo.size -> {
99                             (itemInfo.index + 1).coerceAtMost(
100                                 lazyListState.layoutInfo.totalItemsCount - 1
101                             )
102                         }
103                         initialVelocity < -itemInfo.size -> itemInfo.index
104                         // If the velocity is 0 (or less than the size of the item), spring to
105                         // whichever item is closest to the snap point
106                         itemInfo.offset < -itemInfo.size / 2 -> itemInfo.index + 1
107                         else -> itemInfo.index
108                     },
109                 initialVelocity = initialVelocity,
110             )
111         }
112     }
113 
performDecayFlingnull114     private suspend fun ScrollScope.performDecayFling(
115         initialVelocity: Float,
116         startItem: LazyListItemInfo,
117     ): Float {
118         val index =
119             when {
120                 initialVelocity > 0 -> startItem.index + 1
121                 else -> startItem.index
122             }
123         val forward = index > (currentItemInfo?.index ?: return initialVelocity)
124 
125         // Update the animationTarget
126         animationTarget = index
127 
128         var velocityLeft = initialVelocity
129         var lastValue = 0f
130         AnimationState(
131                 initialValue = 0f,
132                 initialVelocity = initialVelocity,
133             )
134             .animateDecay(decayAnimationSpec) {
135                 val delta = value - lastValue
136                 val consumed = scrollBy(delta)
137                 lastValue = value
138                 velocityLeft = this.velocity
139 
140                 val current = currentItemInfo
141                 if (current == null) {
142                     cancelAnimation()
143                     return@animateDecay
144                 }
145 
146                 if (
147                     !forward &&
148                         (current.index < index || current.index == index && current.offset >= 0)
149                 ) {
150                     // 'snap back' to the item as we may have scrolled past it
151                     scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
152                     cancelAnimation()
153                 } else if (
154                     forward &&
155                         (current.index > index || current.index == index && current.offset <= 0)
156                 ) {
157                     // 'snap back' to the item as we may have scrolled past it
158                     scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
159                     cancelAnimation()
160                 } else if (abs(delta - consumed) > 0.5f) {
161                     // avoid rounding errors and stop if anything is unconsumed
162                     cancelAnimation()
163                 }
164             }
165         animationTarget = null
166         return velocityLeft
167     }
168 
performSpringFlingnull169     private suspend fun ScrollScope.performSpringFling(
170         index: Int,
171         scrollOffset: Int = 0,
172         initialVelocity: Float = 0f,
173     ): Float {
174         // If we don't have a current layout, we can't snap
175         val initialItem = currentItemInfo ?: return initialVelocity
176 
177         val forward = index > initialItem.index
178         // We add 10% on to the size of the current item, to compensate for any item spacing, etc
179         val target = (if (forward) initialItem.size else -initialItem.size) * 1.1f
180 
181         // Update the animationTarget
182         animationTarget = index
183 
184         var velocityLeft = initialVelocity
185         var lastValue = 0f
186         AnimationState(
187                 initialValue = 0f,
188                 initialVelocity = initialVelocity,
189             )
190             .animateTo(
191                 targetValue = target,
192                 animationSpec = snapAnimationSpec,
193             ) {
194                 // Springs can overshoot their target, clamp to the desired range
195                 val coercedValue =
196                     if (forward) {
197                         value.coerceAtMost(target)
198                     } else {
199                         value.coerceAtLeast(target)
200                     }
201                 val delta = coercedValue - lastValue
202                 val consumed = scrollBy(delta)
203                 lastValue = coercedValue
204                 velocityLeft = this.velocity
205 
206                 val current = currentItemInfo
207                 if (current == null) {
208                     cancelAnimation()
209                     return@animateTo
210                 }
211 
212                 if (scrolledPastItem(initialVelocity, current, index, scrollOffset)) {
213                     // If we've scrolled to/past the item, stop the animation. We may also need to
214                     // 'snap back' to the item as we may have scrolled past it
215                     scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
216                     cancelAnimation()
217                 } else if (abs(delta - consumed) > 0.5f) {
218                     // avoid rounding errors and stop if anything is unconsumed
219                     cancelAnimation()
220                 }
221             }
222         animationTarget = null
223         return velocityLeft
224     }
225 
calculateScrollOffsetToItemnull226     private fun LazyListState.calculateScrollOffsetToItem(index: Int): Int {
227         return layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }?.offset ?: 0
228     }
229 
230     private val currentItemInfo: LazyListItemInfo?
231         get() =
232             lazyListState.layoutInfo.visibleItemsInfo
233                 .asSequence()
<lambda>null234                 .filter { it.offset <= 0 && it.offset + it.size > 0 }
235                 .lastOrNull()
236 }
237 
scrolledPastItemnull238 private fun scrolledPastItem(
239     initialVelocity: Float,
240     currentItem: LazyListItemInfo,
241     targetIndex: Int,
242     targetScrollOffset: Int = 0,
243 ): Boolean {
244     return if (initialVelocity > 0) {
245         // forward
246         currentItem.index > targetIndex ||
247             (currentItem.index == targetIndex && currentItem.offset <= targetScrollOffset)
248     } else {
249         // backwards
250         currentItem.index < targetIndex ||
251             (currentItem.index == targetIndex && currentItem.offset >= targetScrollOffset)
252     }
253 }
254 
DecayAnimationSpecnull255 private fun DecayAnimationSpec<Float>.canFlingPastCurrentItem(
256     currentItem: LazyListItemInfo,
257     initialVelocity: Float,
258 ): Boolean {
259     val targetValue =
260         calculateTargetValue(
261             initialValue = currentItem.offset.toFloat(),
262             initialVelocity = initialVelocity,
263         )
264     return when {
265         // forward. We add 10% onto the size to cater for any item spacing
266         initialVelocity > 0 -> targetValue <= -(currentItem.size * 1.1f)
267         // backwards. We add 10% onto the size to cater for any item spacing
268         else -> targetValue >= (currentItem.size * 0.1f)
269     }
270 }
271