1 /*
2 * Copyright 2020 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
18
19 import androidx.annotation.IntRange as AndroidXIntRange
20 import androidx.compose.foundation.ExperimentalFoundationApi
21 import androidx.compose.foundation.MutatePriority
22 import androidx.compose.foundation.gestures.Orientation
23 import androidx.compose.foundation.gestures.ScrollScope
24 import androidx.compose.foundation.gestures.ScrollableState
25 import androidx.compose.foundation.interaction.InteractionSource
26 import androidx.compose.foundation.interaction.MutableInteractionSource
27 import androidx.compose.foundation.internal.checkPrecondition
28 import androidx.compose.foundation.lazy.LazyListState.Companion.Saver
29 import androidx.compose.foundation.lazy.layout.AwaitFirstLayoutModifier
30 import androidx.compose.foundation.lazy.layout.CacheWindowLogic
31 import androidx.compose.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
32 import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
33 import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimator
34 import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
35 import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
36 import androidx.compose.foundation.lazy.layout.LazyLayoutScrollDeltaBetweenPasses
37 import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator
38 import androidx.compose.foundation.lazy.layout.animateScrollToItem
39 import androidx.compose.runtime.Composable
40 import androidx.compose.runtime.Stable
41 import androidx.compose.runtime.annotation.FrequentlyChangingValue
42 import androidx.compose.runtime.getValue
43 import androidx.compose.runtime.mutableStateOf
44 import androidx.compose.runtime.neverEqualPolicy
45 import androidx.compose.runtime.remember
46 import androidx.compose.runtime.saveable.Saver
47 import androidx.compose.runtime.saveable.listSaver
48 import androidx.compose.runtime.saveable.rememberSaveable
49 import androidx.compose.runtime.setValue
50 import androidx.compose.runtime.snapshots.Snapshot
51 import androidx.compose.ui.layout.AlignmentLine
52 import androidx.compose.ui.layout.MeasureResult
53 import androidx.compose.ui.layout.Remeasurement
54 import androidx.compose.ui.layout.RemeasurementModifier
55 import androidx.compose.ui.unit.Constraints
56 import androidx.compose.ui.unit.Density
57 import androidx.compose.ui.util.fastRoundToInt
58 import androidx.compose.ui.util.traceValue
59 import kotlin.coroutines.EmptyCoroutineContext
60 import kotlin.math.abs
61 import kotlinx.coroutines.CoroutineScope
62 import kotlinx.coroutines.launch
63
64 /**
65 * Creates a [LazyListState] that is remembered across compositions.
66 *
67 * Changes to the provided initial values will **not** result in the state being recreated or
68 * changed in any way if it has already been created.
69 *
70 * @param initialFirstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex]
71 * @param initialFirstVisibleItemScrollOffset the initial value for
72 * [LazyListState.firstVisibleItemScrollOffset]
73 */
74 @Composable
rememberLazyListStatenull75 fun rememberLazyListState(
76 initialFirstVisibleItemIndex: Int = 0,
77 initialFirstVisibleItemScrollOffset: Int = 0
78 ): LazyListState {
79 return rememberSaveable(saver = LazyListState.Saver) {
80 LazyListState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset)
81 }
82 }
83
84 /**
85 * Creates a [LazyListState] that is remembered across compositions.
86 *
87 * Changes to the provided initial values will **not** result in the state being recreated or
88 * changed in any way if it has already been created.
89 *
90 * @param initialFirstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex]
91 * @param initialFirstVisibleItemScrollOffset the initial value for
92 * [LazyListState.firstVisibleItemScrollOffset]
93 * @param prefetchStrategy the [LazyListPrefetchStrategy] to use for prefetching content in this
94 * list
95 */
96 @ExperimentalFoundationApi
97 @Composable
rememberLazyListStatenull98 fun rememberLazyListState(
99 initialFirstVisibleItemIndex: Int = 0,
100 initialFirstVisibleItemScrollOffset: Int = 0,
101 prefetchStrategy: LazyListPrefetchStrategy = remember { LazyListPrefetchStrategy() },
102 ): LazyListState {
<lambda>null103 return rememberSaveable(prefetchStrategy, saver = LazyListState.saver(prefetchStrategy)) {
104 LazyListState(
105 initialFirstVisibleItemIndex,
106 initialFirstVisibleItemScrollOffset,
107 prefetchStrategy
108 )
109 }
110 }
111
112 /**
113 * Creates a [LazyListState] that is remembered across compositions.
114 *
115 * Changes to the provided initial values will **not** result in the state being recreated or
116 * changed in any way if it has already been created.
117 *
118 * @param cacheWindow specifies the size of the ahead and behind window to be used as per
119 * [LazyLayoutCacheWindow].
120 * @param initialFirstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex]
121 * @param initialFirstVisibleItemScrollOffset the initial value for
122 * [LazyListState.firstVisibleItemScrollOffset]
123 */
124 @ExperimentalFoundationApi
125 @Composable
rememberLazyListStatenull126 fun rememberLazyListState(
127 cacheWindow: LazyLayoutCacheWindow,
128 initialFirstVisibleItemIndex: Int = 0,
129 initialFirstVisibleItemScrollOffset: Int = 0
130 ): LazyListState {
131 return rememberSaveable(cacheWindow, saver = LazyListState.saver(cacheWindow)) {
132 LazyListState(
133 cacheWindow,
134 initialFirstVisibleItemIndex,
135 initialFirstVisibleItemScrollOffset
136 )
137 }
138 }
139
140 /**
141 * A state object that can be hoisted to control and observe scrolling.
142 *
143 * In most cases, this will be created via [rememberLazyListState].
144 *
145 * @param firstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex]
146 * @param firstVisibleItemScrollOffset the initial value for
147 * [LazyListState.firstVisibleItemScrollOffset]
148 * @param prefetchStrategy the [LazyListPrefetchStrategy] to use for prefetching content in this
149 * list
150 */
151 @OptIn(ExperimentalFoundationApi::class)
152 @Stable
153 class LazyListState
154 @ExperimentalFoundationApi
155 constructor(
156 firstVisibleItemIndex: Int = 0,
157 firstVisibleItemScrollOffset: Int = 0,
158 internal val prefetchStrategy: LazyListPrefetchStrategy = LazyListPrefetchStrategy(),
159 ) : ScrollableState {
160
161 /**
162 * @param cacheWindow specifies the size of the ahead and behind window to be used as per
163 * [LazyLayoutCacheWindow].
164 * @param firstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex]
165 * @param firstVisibleItemScrollOffset the initial value for
166 * [LazyListState.firstVisibleItemScrollOffset]
167 */
168 @ExperimentalFoundationApi
169 constructor(
170 cacheWindow: LazyLayoutCacheWindow,
171 firstVisibleItemIndex: Int = 0,
172 firstVisibleItemScrollOffset: Int = 0,
173 ) : this(
174 firstVisibleItemIndex,
175 firstVisibleItemScrollOffset,
176 LazyListCacheWindowStrategy(cacheWindow)
177 )
178
179 /**
180 * @param firstVisibleItemIndex the initial value for [LazyListState.firstVisibleItemIndex]
181 * @param firstVisibleItemScrollOffset the initial value for
182 * [LazyListState.firstVisibleItemScrollOffset]
183 */
184 constructor(
185 firstVisibleItemIndex: Int = 0,
186 firstVisibleItemScrollOffset: Int = 0
187 ) : this(firstVisibleItemIndex, firstVisibleItemScrollOffset, LazyListPrefetchStrategy())
188
189 internal var hasLookaheadOccurred: Boolean = false
190 private set
191
192 internal var approachLayoutInfo: LazyListMeasureResult? = null
193 private set
194
195 // always execute requests in high priority
196 private var executeRequestsInHighPriorityMode = false
197
198 /** The holder class for the current scroll position. */
199 private val scrollPosition =
200 LazyListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)
201
202 /**
203 * The index of the first item that is visible within the scrollable viewport area not including
204 * items in the content padding region. For the first visible item that includes items in the
205 * content padding please use [LazyListLayoutInfo.visibleItemsInfo].
206 *
207 * Note that this property is observable and if you use it in the composable function it will be
208 * recomposed on every change causing potential performance issues.
209 *
210 * If you want to run some side effects like sending an analytics event or updating a state
211 * based on this value consider using "snapshotFlow":
212 *
213 * @sample androidx.compose.foundation.samples.UsingListScrollPositionForSideEffectSample
214 *
215 * If you need to use it in the composition then consider wrapping the calculation into a
216 * derived state in order to only have recompositions when the derived value changes:
217 *
218 * @sample androidx.compose.foundation.samples.UsingListScrollPositionInCompositionSample
219 */
220 val firstVisibleItemIndex: Int
221 @FrequentlyChangingValue get() = scrollPosition.index
222
223 /**
224 * The scroll offset of the first visible item. Scrolling forward is positive - i.e., the amount
225 * that the item is offset backwards.
226 *
227 * Note that this property is observable and if you use it in the composable function it will be
228 * recomposed on every scroll causing potential performance issues.
229 *
230 * @see firstVisibleItemIndex for samples with the recommended usage patterns.
231 */
232 val firstVisibleItemScrollOffset: Int
233 @FrequentlyChangingValue get() = scrollPosition.scrollOffset
234
235 /** Backing state for [layoutInfo] */
236 private val layoutInfoState = mutableStateOf(EmptyLazyListMeasureResult, neverEqualPolicy())
237
238 /**
239 * The object of [LazyListLayoutInfo] calculated during the last layout pass. For example, you
240 * can use it to calculate what items are currently visible.
241 *
242 * Note that this property is observable and is updated after every scroll or remeasure. If you
243 * use it in the composable function it will be recomposed on every change causing potential
244 * performance issues including infinity recomposition loop. Therefore, avoid using it in the
245 * composition.
246 *
247 * If you want to run some side effects like sending an analytics event or updating a state
248 * based on this value consider using "snapshotFlow":
249 *
250 * @sample androidx.compose.foundation.samples.UsingListLayoutInfoForSideEffectSample
251 */
252 val layoutInfo: LazyListLayoutInfo
253 @FrequentlyChangingValue get() = layoutInfoState.value
254
255 /**
256 * [InteractionSource] that will be used to dispatch drag events when this list is being
257 * dragged. If you want to know whether the fling (or animated scroll) is in progress, use
258 * [isScrollInProgress].
259 */
260 val interactionSource: InteractionSource
261 get() = internalInteractionSource
262
263 internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()
264
265 /**
266 * The amount of scroll to be consumed in the next layout pass. Scrolling forward is negative
267 * - that is, it is the amount that the items are offset in y
268 */
269 internal var scrollToBeConsumed = 0f
270 private set
271
272 internal val density: Density
273 get() = layoutInfoState.value.density
274
275 /**
276 * The ScrollableController instance. We keep it as we need to call stopAnimation on it once we
277 * reached the end of the list.
278 */
<lambda>null279 private val scrollableState = ScrollableState { -onScroll(-it) }
280
281 /** Only used for testing to confirm that we're not making too many measure passes */
282 /*@VisibleForTesting*/
283 internal var numMeasurePasses: Int = 0
284 private set
285
286 /** Only used for testing to disable prefetching when needed to test the main logic. */
287 /*@VisibleForTesting*/
288 internal var prefetchingEnabled: Boolean = true
289
290 /**
291 * The [Remeasurement] object associated with our layout. It allows us to remeasure
292 * synchronously during scroll.
293 */
294 internal var remeasurement: Remeasurement? = null
295 private set
296
297 /** The modifier which provides [remeasurement]. */
298 internal val remeasurementModifier =
299 object : RemeasurementModifier {
onRemeasurementAvailablenull300 override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
301 this@LazyListState.remeasurement = remeasurement
302 }
303 }
304
305 /**
306 * Provides a modifier which allows to delay some interactions (e.g. scroll) until layout is
307 * ready.
308 */
309 internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
310
311 internal val itemAnimator = LazyLayoutItemAnimator<LazyListMeasuredItem>()
312
313 internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
314
315 internal val prefetchState =
<lambda>null316 LazyLayoutPrefetchState(prefetchStrategy.prefetchScheduler) {
317 with(prefetchStrategy) {
318 onNestedPrefetch(Snapshot.withoutReadObservation { firstVisibleItemIndex })
319 }
320 }
321
322 private val prefetchScope: LazyListPrefetchScope =
323 object : LazyListPrefetchScope {
schedulePrefetchnull324 override fun schedulePrefetch(
325 index: Int,
326 onPrefetchFinished: (LazyListPrefetchResultScope.() -> Unit)?
327 ): LazyLayoutPrefetchState.PrefetchHandle {
328 // Without read observation since this can be triggered from scroll - this will then
329 // cause us to recompose when the measure result changes. We don't care since the
330 // prefetch is best effort.
331 val lastMeasureResult = Snapshot.withoutReadObservation { layoutInfoState.value }
332 return prefetchState.schedulePrecompositionAndPremeasure(
333 index,
334 lastMeasureResult.childConstraints,
335 executeRequestsInHighPriorityMode
336 ) {
337 if (onPrefetchFinished != null) {
338 var mainAxisItemSize = 0
339 repeat(placeablesCount) {
340 mainAxisItemSize +=
341 if (lastMeasureResult.orientation == Orientation.Vertical) {
342 getSize(it).height
343 } else {
344 getSize(it).width
345 }
346 }
347
348 onPrefetchFinished.invoke(
349 LazyListPrefetchResultScopeImpl(index, mainAxisItemSize)
350 )
351 }
352 }
353 }
354 }
355
356 /** Stores currently pinned items which are always composed. */
357 internal val pinnedItems = LazyLayoutPinnedItemList()
358
359 internal val nearestRange: IntRange by scrollPosition.nearestRangeState
360
361 /**
362 * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
363 * pixels.
364 *
365 * @param index the index to which to scroll. Must be non-negative.
366 * @param scrollOffset the offset that the item should end up after the scroll. Note that
367 * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
368 * scroll the item further upward (taking it partly offscreen).
369 */
scrollToItemnull370 suspend fun scrollToItem(@AndroidXIntRange(from = 0) index: Int, scrollOffset: Int = 0) {
371 scroll { snapToItemIndexInternal(index, scrollOffset, forceRemeasure = true) }
372 }
373
374 internal val measurementScopeInvalidator = ObservableScopeInvalidator()
375
376 /**
377 * Requests the item at [index] to be at the start of the viewport during the next remeasure,
378 * offset by [scrollOffset], and schedules a remeasure.
379 *
380 * The scroll position will be updated to the requested position rather than maintain the index
381 * based on the first visible item key (when a data set change will also be applied during the
382 * next remeasure), but *only* for the next remeasure.
383 *
384 * Any scroll in progress will be cancelled.
385 *
386 * @param index the index to which to scroll. Must be non-negative.
387 * @param scrollOffset the offset that the item should end up after the scroll. Note that
388 * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
389 * scroll the item further upward (taking it partly offscreen).
390 */
requestScrollToItemnull391 fun requestScrollToItem(@AndroidXIntRange(from = 0) index: Int, scrollOffset: Int = 0) {
392 // Cancel any scroll in progress.
393 if (isScrollInProgress) {
394 layoutInfoState.value.coroutineScope.launch { scroll {} }
395 }
396
397 snapToItemIndexInternal(index, scrollOffset, forceRemeasure = false)
398 }
399
400 /**
401 * Snaps to the requested scroll position. Synchronously executes remeasure if [forceRemeasure]
402 * is true, and schedules a remeasure if false.
403 */
snapToItemIndexInternalnull404 internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int, forceRemeasure: Boolean) {
405 val positionChanged =
406 scrollPosition.index != index || scrollPosition.scrollOffset != scrollOffset
407 // sometimes this method is called not to scroll, but to stay on the same index when
408 // the data changes, as by default we maintain the scroll position by key, not index.
409 // when this happens we don't need to reset the animations as from the user perspective
410 // we didn't scroll anywhere and if there is an offset change for an item, this change
411 // should be animated.
412 // however, when the request is to really scroll to a different position, we have to
413 // reset previously known item positions as we don't want offset changes to be animated.
414 // this offset should be considered as a scroll, not the placement change.
415 if (positionChanged) {
416 itemAnimator.reset()
417 // we changed positions, cancel existing requests and wait for the next scroll to
418 // refill the window
419 (prefetchStrategy as? CacheWindowLogic)?.resetStrategy()
420 }
421 scrollPosition.requestPositionAndForgetLastKnownKey(index, scrollOffset)
422
423 if (forceRemeasure) {
424 remeasurement?.forceRemeasure()
425 } else {
426 measurementScopeInvalidator.invalidateScope()
427 }
428 }
429
430 /**
431 * Call this function to take control of scrolling and gain the ability to send scroll events
432 * via [ScrollScope.scrollBy]. All actions that change the logical scroll position must be
433 * performed within a [scroll] block (even if they don't call any other methods on this object)
434 * in order to guarantee that mutual exclusion is enforced.
435 *
436 * If [scroll] is called from elsewhere, this will be canceled.
437 */
scrollnull438 override suspend fun scroll(
439 scrollPriority: MutatePriority,
440 block: suspend ScrollScope.() -> Unit
441 ) {
442 awaitLayoutModifier.waitForFirstLayout()
443 scrollableState.scroll(scrollPriority, block)
444 }
445
dispatchRawDeltanull446 override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)
447
448 override val isScrollInProgress: Boolean
449 get() = scrollableState.isScrollInProgress
450
451 override var canScrollForward: Boolean by mutableStateOf(false)
452 private set
453
454 override var canScrollBackward: Boolean by mutableStateOf(false)
455 private set
456
457 @get:Suppress("GetterSetterNames")
458 override val lastScrolledForward: Boolean
459 get() = scrollableState.lastScrolledForward
460
461 @get:Suppress("GetterSetterNames")
462 override val lastScrolledBackward: Boolean
463 get() = scrollableState.lastScrolledBackward
464
465 internal val placementScopeInvalidator = ObservableScopeInvalidator()
466
467 // TODO: Coroutine scrolling APIs will allow this to be private again once we have more
468 // fine-grained control over scrolling
469 /*@VisibleForTesting*/
470 internal fun onScroll(distance: Float): Float {
471 if (distance < 0 && !canScrollForward || distance > 0 && !canScrollBackward) {
472 return 0f
473 }
474 checkPrecondition(abs(scrollToBeConsumed) <= 0.5f) {
475 "entered drag with non-zero pending scroll"
476 }
477 executeRequestsInHighPriorityMode = true
478 scrollToBeConsumed += distance
479
480 // scrollToBeConsumed will be consumed synchronously during the forceRemeasure invocation
481 // inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if
482 // we have less than 0.5 pixels
483 if (abs(scrollToBeConsumed) > 0.5f) {
484 val preScrollToBeConsumed = scrollToBeConsumed
485 val intDelta = scrollToBeConsumed.fastRoundToInt()
486
487 var scrolledLayoutInfo =
488 layoutInfoState.value.copyWithScrollDeltaWithoutRemeasure(
489 delta = intDelta,
490 updateAnimations = !hasLookaheadOccurred
491 )
492 if (scrolledLayoutInfo != null && this.approachLayoutInfo != null) {
493 // if we were able to scroll the lookahead layout info without remeasure, lets
494 // try to do the same for approach layout info (sometimes they diverge).
495 val scrolledApproachLayoutInfo =
496 approachLayoutInfo?.copyWithScrollDeltaWithoutRemeasure(
497 delta = intDelta,
498 updateAnimations = true
499 )
500 if (scrolledApproachLayoutInfo != null) {
501 // we can apply scroll delta for both phases without remeasure
502 approachLayoutInfo = scrolledApproachLayoutInfo
503 } else {
504 // we can't apply scroll delta for approach, so we have to remeasure
505 scrolledLayoutInfo = null
506 }
507 }
508
509 if (scrolledLayoutInfo != null) {
510 applyMeasureResult(
511 result = scrolledLayoutInfo,
512 isLookingAhead = hasLookaheadOccurred,
513 visibleItemsStayedTheSame = true
514 )
515 // we don't need to remeasure, so we only trigger re-placement:
516 placementScopeInvalidator.invalidateScope()
517
518 notifyPrefetchOnScroll(
519 preScrollToBeConsumed - scrollToBeConsumed,
520 scrolledLayoutInfo
521 )
522 } else {
523 remeasurement?.forceRemeasure()
524 notifyPrefetchOnScroll(preScrollToBeConsumed - scrollToBeConsumed, this.layoutInfo)
525 }
526 }
527
528 // here scrollToBeConsumed is already consumed during the forceRemeasure invocation
529 if (abs(scrollToBeConsumed) <= 0.5f) {
530 // We consumed all of it - we'll hold onto the fractional scroll for later, so report
531 // that we consumed the whole thing
532 return distance
533 } else {
534 val scrollConsumed = distance - scrollToBeConsumed
535 // We did not consume all of it - return the rest to be consumed elsewhere (e.g.,
536 // nested scrolling)
537 scrollToBeConsumed = 0f // We're not consuming the rest, give it back
538 return scrollConsumed
539 }
540 }
541
notifyPrefetchOnScrollnull542 private fun notifyPrefetchOnScroll(delta: Float, layoutInfo: LazyListLayoutInfo) {
543 if (prefetchingEnabled) {
544 with(prefetchStrategy) { prefetchScope.onScroll(delta, layoutInfo) }
545 }
546 }
547
548 /**
549 * Animate (smooth scroll) to the given item.
550 *
551 * @param index the index to which to scroll. Must be non-negative.
552 * @param scrollOffset the offset that the item should end up after the scroll. Note that
553 * positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
554 * scroll the item further upward (taking it partly offscreen).
555 */
animateScrollToItemnull556 suspend fun animateScrollToItem(@AndroidXIntRange(from = 0) index: Int, scrollOffset: Int = 0) {
557 scroll {
558 LazyLayoutScrollScope(this@LazyListState, this)
559 .animateScrollToItem(index, scrollOffset, NumberOfItemsToTeleport, density)
560 }
561 }
562
563 /** Updates the state with the new calculated scroll position and consumed scroll. */
applyMeasureResultnull564 internal fun applyMeasureResult(
565 result: LazyListMeasureResult,
566 isLookingAhead: Boolean,
567 visibleItemsStayedTheSame: Boolean = false
568 ) {
569 // update the prefetch state with the number of nested prefetch items this layout
570 // should use.
571 prefetchState.idealNestedPrefetchCount = result.visibleItemsInfo.size
572
573 if (!isLookingAhead && hasLookaheadOccurred) {
574 // If there was already a lookahead pass, record this result as approach result
575 approachLayoutInfo = result
576 Snapshot.withoutReadObservation {
577 if (
578 _lazyLayoutScrollDeltaBetweenPasses.isActive &&
579 result.firstVisibleItem?.index == scrollPosition.index &&
580 result.firstVisibleItemScrollOffset == scrollPosition.scrollOffset
581 ) {
582 _lazyLayoutScrollDeltaBetweenPasses.stop()
583 }
584 }
585 } else {
586 if (isLookingAhead) {
587 hasLookaheadOccurred = true
588 }
589
590 canScrollBackward = result.canScrollBackward
591 canScrollForward = result.canScrollForward
592 scrollToBeConsumed -= result.consumedScroll
593 layoutInfoState.value = result
594
595 if (visibleItemsStayedTheSame) {
596 scrollPosition.updateScrollOffset(result.firstVisibleItemScrollOffset)
597 } else {
598 traceVisibleItems(result) // trace when visible window changed
599 scrollPosition.updateFromMeasureResult(result)
600 if (prefetchingEnabled) {
601 with(prefetchStrategy) { prefetchScope.onVisibleItemsUpdated(result) }
602 }
603 }
604
605 if (isLookingAhead) {
606 _lazyLayoutScrollDeltaBetweenPasses.updateScrollDeltaForApproach(
607 result.scrollBackAmount,
608 result.density,
609 result.coroutineScope
610 )
611 }
612 numMeasurePasses++
613 }
614 }
615
traceVisibleItemsnull616 private fun traceVisibleItems(measureResult: LazyListMeasureResult) {
617 val firstVisibleItem = measureResult.visibleItemsInfo.firstOrNull()
618 val lastVisibleItem = measureResult.visibleItemsInfo.lastOrNull()
619 traceValue("firstVisibleItem:index", firstVisibleItem?.index?.toLong() ?: -1L)
620 traceValue("lastVisibleItem:index", lastVisibleItem?.index?.toLong() ?: -1L)
621 }
622
623 internal val scrollDeltaBetweenPasses: Float
624 get() = _lazyLayoutScrollDeltaBetweenPasses.scrollDeltaBetweenPasses
625
626 private val _lazyLayoutScrollDeltaBetweenPasses = LazyLayoutScrollDeltaBetweenPasses()
627
628 /**
629 * When the user provided custom keys for the items we can try to detect when there were items
630 * added or removed before our current first visible item and keep this item as the first
631 * visible one even given that its index has been changed. The scroll position will not be
632 * updated if [requestScrollToItem] was called since the last time this method was called.
633 */
updateScrollPositionIfTheFirstItemWasMovednull634 internal fun updateScrollPositionIfTheFirstItemWasMoved(
635 itemProvider: LazyListItemProvider,
636 firstItemIndex: Int
637 ): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex)
638
639 companion object {
640 /** The default [Saver] implementation for [LazyListState]. */
641 val Saver: Saver<LazyListState, *> =
642 listSaver(
643 save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) },
644 restore = {
645 LazyListState(
646 firstVisibleItemIndex = it[0],
647 firstVisibleItemScrollOffset = it[1]
648 )
649 }
650 )
651
652 /**
653 * A [Saver] implementation for [LazyListState] that handles setting a custom
654 * [LazyListPrefetchStrategy].
655 */
656 internal fun saver(prefetchStrategy: LazyListPrefetchStrategy): Saver<LazyListState, *> =
657 listSaver(
658 save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) },
659 restore = {
660 LazyListState(
661 firstVisibleItemIndex = it[0],
662 firstVisibleItemScrollOffset = it[1],
663 prefetchStrategy
664 )
665 }
666 )
667
668 /**
669 * A [Saver] implementation for [LazyListState] that handles setting a custom
670 * [LazyLayoutCacheWindow].
671 */
672 internal fun saver(cacheWindow: LazyLayoutCacheWindow): Saver<LazyListState, *> =
673 listSaver(
674 save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) },
675 restore = {
676 LazyListState(
677 firstVisibleItemIndex = it[0],
678 firstVisibleItemScrollOffset = it[1],
679 cacheWindow = cacheWindow
680 )
681 }
682 )
683 }
684 }
685
686 private val EmptyLazyListMeasureResult =
687 LazyListMeasureResult(
688 firstVisibleItem = null,
689 firstVisibleItemScrollOffset = 0,
690 canScrollForward = false,
691 consumedScroll = 0f,
692 measureResult =
693 object : MeasureResult {
694 override val width: Int = 0
695 override val height: Int = 0
696
697 @Suppress("PrimitiveInCollection")
698 override val alignmentLines: Map<AlignmentLine, Int> = emptyMap()
699
placeChildrennull700 override fun placeChildren() {}
701 },
702 scrollBackAmount = 0f,
703 visibleItemsInfo = emptyList(),
704 viewportStartOffset = 0,
705 viewportEndOffset = 0,
706 totalItemsCount = 0,
707 reverseLayout = false,
708 orientation = Orientation.Vertical,
709 afterContentPadding = 0,
710 mainAxisItemSpacing = 0,
711 remeasureNeeded = false,
712 coroutineScope = CoroutineScope(EmptyCoroutineContext),
713 density = Density(1f),
714 childConstraints = Constraints()
715 )
716
717 private const val NumberOfItemsToTeleport = 100
718