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