1 /*
2  * Copyright 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 androidx.compose.foundation.lazy.layout
18 
19 import androidx.compose.animation.core.AnimationState
20 import androidx.compose.animation.core.AnimationVector1D
21 import androidx.compose.animation.core.animateTo
22 import androidx.compose.animation.core.copy
23 import androidx.compose.foundation.gestures.ScrollScope
24 import androidx.compose.foundation.internal.requirePrecondition
25 import androidx.compose.ui.unit.Density
26 import androidx.compose.ui.unit.dp
27 import kotlin.coroutines.cancellation.CancellationException
28 import kotlin.math.abs
29 
30 private class ItemFoundInScroll(
31     val itemOffset: Int,
32     val previousAnimation: AnimationState<Float, AnimationVector1D>
33 ) : CancellationException()
34 
35 private val TargetDistance = 2500.dp
36 private val BoundDistance = 1500.dp
37 private val MinimumDistance = 50.dp
38 
39 private const val DEBUG = false
40 
debugLognull41 private inline fun debugLog(generateMsg: () -> String) {
42     if (DEBUG) {
43         println("LazyScrolling: ${generateMsg()}")
44     }
45 }
46 
47 /**
48  * A [ScrollScope] to allow customization of scroll sessions in LazyLayouts. This scope contains
49  * additional information to perform a custom scroll session in a scrollable LazyLayout.
50  *
51  * For implementations for the most common layouts see:
52  *
53  * @see androidx.compose.foundation.lazy.grid.LazyLayoutScrollScope
54  * @see androidx.compose.foundation.lazy.staggeredgrid.LazyLayoutScrollScope
55  * @see androidx.compose.foundation.lazy.LazyLayoutScrollScope
56  * @see androidx.compose.foundation.pager.LazyLayoutScrollScope
57  */
58 interface LazyLayoutScrollScope : ScrollScope {
59 
60     /** The index of the first visible item in the lazy layout. */
61     val firstVisibleItemIndex: Int
62 
63     /** The offset of the first visible item. */
64     val firstVisibleItemScrollOffset: Int
65 
66     /**
67      * The last visible item in the LazyLayout, lastVisibleItemIndex - firstVisibleItemOffset + 1 is
68      * the number of visible items.
69      */
70     val lastVisibleItemIndex: Int
71 
72     /** The total item count. */
73     val itemCount: Int
74 
75     /**
76      * Immediately scroll to [index] and settle in [offset].
77      *
78      * @param index The position index where we should immediately snap to.
79      * @param offset The offset where we should immediately snap to.
80      */
snapToItemnull81     fun snapToItem(index: Int, offset: Int = 0)
82 
83     /**
84      * The "expected" distance to [targetIndex]. This means the "expected" offset of [targetIndex]
85      * in the layout. In a LazyLayout, non-visible items don't have an actual offset, so this method
86      * should return an approximation of the scroll offset to [targetIndex]. If [targetIndex] is
87      * visible, then an "exact" offset should be provided.
88      *
89      * @param targetIndex The index position with respect to which this calculation should be done.
90      * @param targetOffset The offset with respect to which this calculation should be done.
91      * @return The expected distance to scroll so [targetIndex] is the firstVisibleItemIndex with
92      *   [targetOffset] as the firstVisibleItemScrollOffset.
93      */
94     fun calculateDistanceTo(targetIndex: Int, targetOffset: Int = 0): Int
95 }
96 
97 internal fun LazyLayoutScrollScope.isItemVisible(index: Int): Boolean {
98     return index in firstVisibleItemIndex..lastVisibleItemIndex
99 }
100 
101 /**
102  * Default animateScrollToItem logic to be used by any [LazyLayoutScrollScope].
103  *
104  * @param index Target index to animate to.
105  * @param scrollOffset Target offset to animate to.
106  * @param numOfItemsForTeleport In case teleporting is needed, the number of items to jump
107  *   ahead/back to avoid composing intermediate items.
108  * @param density A [Density] instance.
109  */
animateScrollToItemnull110 internal suspend fun LazyLayoutScrollScope.animateScrollToItem(
111     index: Int,
112     scrollOffset: Int,
113     numOfItemsForTeleport: Int,
114     density: Density
115 ) {
116     requirePrecondition(index >= 0f) { "Index should be non-negative" }
117 
118     try {
119         val targetDistancePx = with(density) { TargetDistance.toPx() }
120         val boundDistancePx = with(density) { BoundDistance.toPx() }
121         val minDistancePx = with(density) { MinimumDistance.toPx() }
122         var loop = true
123         var anim = AnimationState(0f)
124 
125         if (isItemVisible(index)) {
126             val targetItemInitialOffset = calculateDistanceTo(index)
127             // It's already visible, just animate directly
128             throw ItemFoundInScroll(targetItemInitialOffset, anim)
129         }
130         val forward = index > firstVisibleItemIndex
131 
132         fun isOvershot(): Boolean {
133             // Did we scroll past the item?
134             @Suppress("RedundantIf") // It's way easier to understand the logic this way
135             return if (forward) {
136                 if (firstVisibleItemIndex > index) {
137                     true
138                 } else if (
139                     firstVisibleItemIndex == index && firstVisibleItemScrollOffset > scrollOffset
140                 ) {
141                     true
142                 } else {
143                     false
144                 }
145             } else { // backward
146                 if (firstVisibleItemIndex < index) {
147                     true
148                 } else if (
149                     firstVisibleItemIndex == index && firstVisibleItemScrollOffset < scrollOffset
150                 ) {
151                     true
152                 } else {
153                     false
154                 }
155             }
156         }
157 
158         var loops = 1
159         while (loop && itemCount > 0) {
160             val expectedDistance = calculateDistanceTo(index) + scrollOffset
161             val target =
162                 if (abs(expectedDistance) < targetDistancePx) {
163                     val absTargetPx = maxOf(abs(expectedDistance.toFloat()), minDistancePx)
164                     if (forward) absTargetPx else -absTargetPx
165                 } else {
166                     if (forward) targetDistancePx else -targetDistancePx
167                 }
168 
169             debugLog {
170                 "Scrolling to index=$index offset=$scrollOffset from " +
171                     "index=$firstVisibleItemIndex offset=$firstVisibleItemScrollOffset with " +
172                     "calculated target=$target"
173             }
174 
175             anim = anim.copy(value = 0f)
176             var prevValue = 0f
177             anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) {
178                 // If we haven't found the item yet, check if it's visible.
179                 debugLog { "firstVisibleItemIndex=$firstVisibleItemIndex" }
180                 if (!isItemVisible(index)) {
181                     // Springs can overshoot their target, clamp to the desired range
182                     val coercedValue =
183                         if (target > 0) {
184                             value.coerceAtMost(target)
185                         } else {
186                             value.coerceAtLeast(target)
187                         }
188                     val delta = coercedValue - prevValue
189                     debugLog {
190                         "Scrolling by $delta (target: $target, coercedValue: $coercedValue)"
191                     }
192 
193                     val consumed = scrollBy(delta)
194                     if (isItemVisible(index)) {
195                         debugLog { "Found the item after performing scrollBy()" }
196                     } else if (!isOvershot()) {
197                         if (delta != consumed) {
198                             debugLog { "Hit end without finding the item" }
199                             cancelAnimation()
200                             loop = false
201                             return@animateTo
202                         }
203                         prevValue += delta
204                         if (forward) {
205                             if (value > boundDistancePx) {
206                                 debugLog { "Struck bound going forward" }
207                                 cancelAnimation()
208                             }
209                         } else {
210                             if (value < -boundDistancePx) {
211                                 debugLog { "Struck bound going backward" }
212                                 cancelAnimation()
213                             }
214                         }
215 
216                         if (forward) {
217                             if (
218                                 loops >= 2 && index - lastVisibleItemIndex > numOfItemsForTeleport
219                             ) {
220                                 // Teleport
221                                 debugLog { "Teleport forward" }
222                                 snapToItem(index = index - numOfItemsForTeleport, offset = 0)
223                             }
224                         } else {
225                             if (
226                                 loops >= 2 && firstVisibleItemIndex - index > numOfItemsForTeleport
227                             ) {
228                                 // Teleport
229                                 debugLog { "Teleport backward" }
230                                 snapToItem(index = index + numOfItemsForTeleport, offset = 0)
231                             }
232                         }
233                     }
234                 }
235 
236                 // We don't throw ItemFoundInScroll when we snap, because once we've snapped to
237                 // the final position, there's no need to animate to it.
238                 if (isOvershot()) {
239                     debugLog {
240                         "Overshot, " +
241                             "item $firstVisibleItemIndex at  $firstVisibleItemScrollOffset," +
242                             " target is $scrollOffset"
243                     }
244                     snapToItem(index = index, offset = scrollOffset)
245                     loop = false
246                     cancelAnimation()
247                     return@animateTo
248                 } else if (isItemVisible(index)) {
249                     val targetItemOffset = calculateDistanceTo(index)
250                     debugLog { "Found item" }
251                     throw ItemFoundInScroll(targetItemOffset, anim)
252                 }
253             }
254 
255             loops++
256         }
257     } catch (itemFound: ItemFoundInScroll) {
258         // We found it, animate to it
259         // Bring to the requested position - will be automatically stopped if not possible
260         val anim = itemFound.previousAnimation.copy(value = 0f)
261         val target = (itemFound.itemOffset + scrollOffset).toFloat()
262         var prevValue = 0f
263         debugLog { "Seeking by $target at velocity ${itemFound.previousAnimation.velocity}" }
264         anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) {
265             // Springs can overshoot their target, clamp to the desired range
266             val coercedValue =
267                 when {
268                     target > 0 -> {
269                         value.coerceAtMost(target)
270                     }
271                     target < 0 -> {
272                         value.coerceAtLeast(target)
273                     }
274                     else -> {
275                         debugLog { "WARNING: somehow ended up seeking 0px, this shouldn't happen" }
276                         0f
277                     }
278                 }
279             val delta = coercedValue - prevValue
280             debugLog { "Seeking by $delta (coercedValue = $coercedValue)" }
281             val consumed = scrollBy(delta)
282             if (
283                 delta != consumed /* hit the end, stop */ ||
284                     coercedValue != value /* would have overshot, stop */
285             ) {
286                 cancelAnimation()
287             }
288             prevValue += delta
289         }
290         // Once we're finished the animation, snap to the exact position to account for
291         // rounding error (otherwise we tend to end up with the previous item scrolled the
292         // tiniest bit onscreen)
293         // TODO: prevent temporarily scrolling *past* the item
294         snapToItem(index = index, offset = scrollOffset)
295     }
296 }
297