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