1 /*
<lambda>null2  * 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.staggeredgrid
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.gestures.stopScroll
26 import androidx.compose.foundation.interaction.InteractionSource
27 import androidx.compose.foundation.interaction.MutableInteractionSource
28 import androidx.compose.foundation.internal.checkPrecondition
29 import androidx.compose.foundation.internal.requirePrecondition
30 import androidx.compose.foundation.lazy.layout.AwaitFirstLayoutModifier
31 import androidx.compose.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
32 import androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimator
33 import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
34 import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
35 import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
36 import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState.PrefetchHandle
37 import androidx.compose.foundation.lazy.layout.LazyLayoutScrollDeltaBetweenPasses
38 import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator
39 import androidx.compose.foundation.lazy.layout.PrefetchScheduler
40 import androidx.compose.foundation.lazy.layout.animateScrollToItem
41 import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridLaneInfo.Companion.FullSpan
42 import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridLaneInfo.Companion.Unset
43 import androidx.compose.runtime.Composable
44 import androidx.compose.runtime.Stable
45 import androidx.compose.runtime.getValue
46 import androidx.compose.runtime.mutableStateOf
47 import androidx.compose.runtime.neverEqualPolicy
48 import androidx.compose.runtime.saveable.listSaver
49 import androidx.compose.runtime.saveable.rememberSaveable
50 import androidx.compose.runtime.setValue
51 import androidx.compose.ui.layout.Remeasurement
52 import androidx.compose.ui.layout.RemeasurementModifier
53 import androidx.compose.ui.unit.Constraints
54 import kotlin.math.abs
55 import kotlin.math.roundToInt
56 import kotlin.ranges.IntRange
57 import kotlinx.coroutines.launch
58 
59 /**
60  * Creates a [LazyStaggeredGridState] that is remembered across composition.
61  *
62  * Calling this function with different parameters on recomposition WILL NOT recreate or change the
63  * state. Use [LazyStaggeredGridState.scrollToItem] or [LazyStaggeredGridState.animateScrollToItem]
64  * to adjust position instead.
65  *
66  * @param initialFirstVisibleItemIndex initial position for
67  *   [LazyStaggeredGridState.firstVisibleItemIndex]
68  * @param initialFirstVisibleItemScrollOffset initial value for
69  *   [LazyStaggeredGridState.firstVisibleItemScrollOffset]
70  * @return created and memoized [LazyStaggeredGridState] with given parameters.
71  */
72 @Composable
73 fun rememberLazyStaggeredGridState(
74     initialFirstVisibleItemIndex: Int = 0,
75     initialFirstVisibleItemScrollOffset: Int = 0
76 ): LazyStaggeredGridState =
77     rememberSaveable(saver = LazyStaggeredGridState.Saver) {
78         LazyStaggeredGridState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset)
79     }
80 
81 /**
82  * Hoisted state object controlling [LazyVerticalStaggeredGrid] or [LazyHorizontalStaggeredGrid]. In
83  * most cases, it should be created via [rememberLazyStaggeredGridState].
84  */
85 @OptIn(ExperimentalFoundationApi::class)
86 @Stable
87 class LazyStaggeredGridState
88 internal constructor(
89     initialFirstVisibleItems: IntArray,
90     initialFirstVisibleOffsets: IntArray,
91     prefetchScheduler: PrefetchScheduler?
92 ) : ScrollableState {
93     /**
94      * @param initialFirstVisibleItemIndex initial value for [firstVisibleItemIndex]
95      * @param initialFirstVisibleItemOffset initial value for [firstVisibleItemScrollOffset]
96      */
97     constructor(
98         initialFirstVisibleItemIndex: Int = 0,
99         initialFirstVisibleItemOffset: Int = 0
100     ) : this(
101         intArrayOf(initialFirstVisibleItemIndex),
102         intArrayOf(initialFirstVisibleItemOffset),
103         null
104     )
105 
106     internal var hasLookaheadOccurred: Boolean = false
107         private set
108 
109     internal var approachLayoutInfo: LazyStaggeredGridMeasureResult? = null
110         private set
111 
112     /**
113      * Index of the first visible item across all staggered grid lanes. This does not include items
114      * in the content padding region. For the first visible item that includes items in the content
115      * padding please use [LazyStaggeredGridLayoutInfo.visibleItemsInfo].
116      *
117      * This property is observable and when use it in composable function it will be recomposed on
118      * each scroll, potentially causing performance issues.
119      */
120     val firstVisibleItemIndex: Int
121         get() = scrollPosition.index
122 
123     /**
124      * Current offset of the item with [firstVisibleItemIndex] relative to the container start.
125      *
126      * This property is observable and when use it in composable function it will be recomposed on
127      * each scroll, potentially causing performance issues.
128      */
129     val firstVisibleItemScrollOffset: Int
130         get() = scrollPosition.scrollOffset
131 
132     /** holder for current scroll position */
133     internal val scrollPosition =
134         LazyStaggeredGridScrollPosition(
135             initialFirstVisibleItems,
136             initialFirstVisibleOffsets,
137             ::fillNearestIndices
138         )
139 
140     /**
141      * Layout information calculated during last layout pass, with information about currently
142      * visible items and container parameters.
143      *
144      * This property is observable and when use it in composable function it will be recomposed on
145      * each scroll, potentially causing performance issues.
146      */
147     val layoutInfo: LazyStaggeredGridLayoutInfo
148         get() = layoutInfoState.value
149 
150     /** backing state for [layoutInfo] */
151     private val layoutInfoState =
152         mutableStateOf(EmptyLazyStaggeredGridLayoutInfo, neverEqualPolicy())
153 
154     /** storage for lane assignments for each item for consistent scrolling in both directions */
155     internal val laneInfo = LazyStaggeredGridLaneInfo()
156 
157     override var canScrollForward: Boolean by mutableStateOf(false)
158         private set
159 
160     override var canScrollBackward: Boolean by mutableStateOf(false)
161         private set
162 
163     @get:Suppress("GetterSetterNames")
164     override val lastScrolledForward: Boolean
165         get() = scrollableState.lastScrolledForward
166 
167     @get:Suppress("GetterSetterNames")
168     override val lastScrolledBackward: Boolean
169         get() = scrollableState.lastScrolledBackward
170 
171     internal var remeasurement: Remeasurement? = null
172         private set
173 
174     internal val remeasurementModifier =
175         object : RemeasurementModifier {
onRemeasurementAvailablenull176             override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
177                 this@LazyStaggeredGridState.remeasurement = remeasurement
178             }
179         }
180 
181     /**
182      * Provides a modifier which allows to delay some interactions (e.g. scroll) until layout is
183      * ready.
184      */
185     internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
186 
187     internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
188 
189     /** Only used for testing to disable prefetching when needed to test the main logic. */
190     /*@VisibleForTesting*/
191     internal var prefetchingEnabled: Boolean = true
192 
193     /** prefetch state used for precomputing items in the direction of scroll */
194     internal val prefetchState: LazyLayoutPrefetchState = LazyLayoutPrefetchState(prefetchScheduler)
195 
196     /** state controlling the scroll */
<lambda>null197     private val scrollableState = ScrollableState { -onScroll(-it) }
198 
199     /** scroll to be consumed during next/current layout pass */
200     private var scrollToBeConsumed = 0f
201 
scrollToBeConsumednull202     internal fun scrollToBeConsumed(isLookingAhead: Boolean): Float =
203         if (isLookingAhead || !hasLookaheadOccurred) {
204             scrollToBeConsumed
205         } else {
206             scrollDeltaBetweenPasses
207         }
208 
209     /* @VisibleForTesting */
210     internal var measurePassCount = 0
211 
212     /** prefetch state */
213     private var prefetchBaseIndex: Int = -1
214     private val currentItemPrefetchHandles = mutableMapOf<Int, PrefetchHandle>()
215 
216     internal val laneCount
217         get() = layoutInfoState.value.slots.sizes.size
218 
219     /**
220      * [InteractionSource] that will be used to dispatch drag events when this list is being
221      * dragged. If you want to know whether the fling (or animated scroll) is in progress, use
222      * [isScrollInProgress].
223      */
224     val interactionSource
225         get(): InteractionSource = mutableInteractionSource
226 
227     /** backing field mutable field for [interactionSource] */
228     internal val mutableInteractionSource = MutableInteractionSource()
229 
230     /** Stores currently pinned items which are always composed. */
231     internal val pinnedItems = LazyLayoutPinnedItemList()
232 
233     internal val itemAnimator = LazyLayoutItemAnimator<LazyStaggeredGridMeasuredItem>()
234 
235     internal val nearestRange: IntRange by scrollPosition.nearestRangeState
236 
237     internal val placementScopeInvalidator = ObservableScopeInvalidator()
238 
239     /**
240      * Call this function to take control of scrolling and gain the ability to send scroll events
241      * via [ScrollScope.scrollBy]. All actions that change the logical scroll position must be
242      * performed within a [scroll] block (even if they don't call any other methods on this object)
243      * in order to guarantee that mutual exclusion is enforced.
244      *
245      * If [scroll] is called from elsewhere, this will be canceled.
246      */
scrollnull247     override suspend fun scroll(
248         scrollPriority: MutatePriority,
249         block: suspend ScrollScope.() -> Unit
250     ) {
251         awaitLayoutModifier.waitForFirstLayout()
252         scrollableState.scroll(scrollPriority, block)
253     }
254 
255     /**
256      * Whether this [scrollableState] is currently scrolling by gesture, fling or programmatically
257      * or not.
258      */
259     override val isScrollInProgress: Boolean
260         get() = scrollableState.isScrollInProgress
261 
262     /** Main scroll callback which adjusts scroll delta and remeasures layout */
onScrollnull263     private fun onScroll(distance: Float): Float {
264         if (distance < 0 && !canScrollForward || distance > 0 && !canScrollBackward) {
265             return 0f
266         }
267         checkPrecondition(abs(scrollToBeConsumed) <= 0.5f) {
268             "entered drag with non-zero pending scroll"
269         }
270         scrollToBeConsumed += distance
271 
272         // scrollToBeConsumed will be consumed synchronously during the forceRemeasure invocation
273         // inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if
274         // we have less than 0.5 pixels
275         if (abs(scrollToBeConsumed) > 0.5f) {
276             val preScrollToBeConsumed = scrollToBeConsumed
277             val intDelta = scrollToBeConsumed.roundToInt()
278             var scrolledLayoutInfo =
279                 layoutInfoState.value.copyWithScrollDeltaWithoutRemeasure(
280                     delta = intDelta,
281                     updateAnimations = !hasLookaheadOccurred
282                 )
283             if (scrolledLayoutInfo != null && this.approachLayoutInfo != null) {
284                 // if we were able to scroll the lookahead layout info without remeasure, lets
285                 // try to do the same for post lookahead layout info (sometimes they diverge).
286                 val scrolledApproachLayoutInfo =
287                     approachLayoutInfo?.copyWithScrollDeltaWithoutRemeasure(
288                         delta = intDelta,
289                         updateAnimations = true
290                     )
291                 if (scrolledApproachLayoutInfo != null) {
292                     // we can apply scroll delta for both phases without remeasure
293                     approachLayoutInfo = scrolledApproachLayoutInfo
294                 } else {
295                     // we can't apply scroll delta for post lookahead, so we have to remeasure
296                     scrolledLayoutInfo = null
297                 }
298             }
299             if (scrolledLayoutInfo != null) {
300                 applyMeasureResult(
301                     result = scrolledLayoutInfo,
302                     isLookingAhead = hasLookaheadOccurred,
303                     visibleItemsStayedTheSame = true
304                 )
305                 // we don't need to remeasure, so we only trigger re-placement:
306                 placementScopeInvalidator.invalidateScope()
307 
308                 notifyPrefetch(preScrollToBeConsumed - scrollToBeConsumed, scrolledLayoutInfo)
309             } else {
310                 remeasurement?.forceRemeasure()
311 
312                 notifyPrefetch(preScrollToBeConsumed - scrollToBeConsumed)
313             }
314         }
315 
316         // here scrollToBeConsumed is already consumed during the forceRemeasure invocation
317         if (abs(scrollToBeConsumed) <= 0.5f) {
318             // We consumed all of it - we'll hold onto the fractional scroll for later, so report
319             // that we consumed the whole thing
320             return distance
321         } else {
322             val scrollConsumed = distance - scrollToBeConsumed
323             // We did not consume all of it - return the rest to be consumed elsewhere (e.g.,
324             // nested scrolling)
325             scrollToBeConsumed = 0f // We're not consuming the rest, give it back
326             return scrollConsumed
327         }
328     }
329 
330     /**
331      * Instantly brings the item at [index] to the top of layout viewport, offset by [scrollOffset]
332      * pixels.
333      *
334      * @param index the index to which to scroll. MUST NOT be negative.
335      * @param scrollOffset the offset where the item should end up after the scroll. Note that
336      *   positive offset refers to forward scroll, so in a reversed list, positive offset will
337      *   scroll the item further upward (taking it partly offscreen).
338      */
scrollToItemnull339     suspend fun scrollToItem(
340         /* @IntRange(from = 0) */
341         index: Int,
342         scrollOffset: Int = 0
343     ) {
344         scroll { snapToItemInternal(index, scrollOffset, forceRemeasure = true) }
345     }
346 
347     /**
348      * Animate (smooth scroll) to the given item.
349      *
350      * @param index the index to which to scroll. MUST NOT be negative.
351      * @param scrollOffset the offset that the item should end up after the scroll. Note that
352      *   positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
353      *   scroll the item further upward (taking it partly offscreen).
354      */
animateScrollToItemnull355     suspend fun animateScrollToItem(
356         /* @IntRange(from = 0) */
357         index: Int,
358         scrollOffset: Int = 0
359     ) {
360         val layoutInfo = layoutInfoState.value
361         val numOfItemsToTeleport = 100 * layoutInfo.slots.sizes.size
362         scroll {
363             LazyLayoutScrollScope(this@LazyStaggeredGridState, this)
364                 .animateScrollToItem(index, scrollOffset, numOfItemsToTeleport, layoutInfo.density)
365         }
366     }
367 
368     internal val measurementScopeInvalidator = ObservableScopeInvalidator()
369 
370     /**
371      * Requests the item at [index] to be at the start of the viewport during the next remeasure,
372      * offset by [scrollOffset], and schedules a remeasure.
373      *
374      * The scroll position will be updated to the requested position rather than maintain the index
375      * based on the first visible item key (when a data set change will also be applied during the
376      * next remeasure), but *only* for the next remeasure.
377      *
378      * Any scroll in progress will be cancelled.
379      *
380      * @param index the index to which to scroll. Must be non-negative.
381      * @param scrollOffset the offset that the item should end up after the scroll. Note that
382      *   positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
383      *   scroll the item further upward (taking it partly offscreen).
384      */
requestScrollToItemnull385     fun requestScrollToItem(@AndroidXIntRange(from = 0) index: Int, scrollOffset: Int = 0) {
386         // Cancel any scroll in progress.
387         if (isScrollInProgress) {
388             layoutInfoState.value.coroutineScope.launch { stopScroll() }
389         }
390 
391         snapToItemInternal(index, scrollOffset, forceRemeasure = false)
392     }
393 
snapToItemInternalnull394     internal fun snapToItemInternal(index: Int, scrollOffset: Int, forceRemeasure: Boolean) {
395         val positionChanged =
396             scrollPosition.index != index || scrollPosition.scrollOffset != scrollOffset
397         // sometimes this method is called not to scroll, but to stay on the same index when
398         // the data changes, as by default we maintain the scroll position by key, not index.
399         // when this happens we don't need to reset the animations as from the user perspective
400         // we didn't scroll anywhere and if there is an offset change for an item, this change
401         // should be animated.
402         // however, when the request is to really scroll to a different position, we have to
403         // reset previously known item positions as we don't want offset changes to be animated.
404         // this offset should be considered as a scroll, not the placement change.
405         if (positionChanged) {
406             itemAnimator.reset()
407         }
408         val layoutInfo = layoutInfoState.value
409         val visibleItem = layoutInfo.findVisibleItem(index)
410         if (visibleItem != null && positionChanged) {
411             val currentOffset =
412                 if (layoutInfo.orientation == Orientation.Vertical) {
413                     visibleItem.offset.y
414                 } else {
415                     visibleItem.offset.x
416                 }
417             val delta = currentOffset + scrollOffset
418             val offsets =
419                 IntArray(layoutInfo.firstVisibleItemScrollOffsets.size) {
420                     layoutInfo.firstVisibleItemScrollOffsets[it] + delta
421                 }
422             scrollPosition.updateScrollOffset(offsets)
423         } else {
424             scrollPosition.requestPositionAndForgetLastKnownKey(index, scrollOffset)
425         }
426         if (forceRemeasure) {
427             remeasurement?.forceRemeasure()
428         } else {
429             measurementScopeInvalidator.invalidateScope()
430         }
431     }
432 
433     /** Maintain scroll position for item based on custom key if its index has changed. */
updateScrollPositionIfTheFirstItemWasMovednull434     internal fun updateScrollPositionIfTheFirstItemWasMoved(
435         itemProvider: LazyLayoutItemProvider,
436         firstItemIndex: IntArray
437     ): IntArray =
438         scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex)
439 
440     override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)
441 
442     /** Start prefetch of the items based on provided delta */
443     private fun notifyPrefetch(
444         delta: Float,
445         info: LazyStaggeredGridMeasureResult = layoutInfoState.value
446     ) {
447         if (prefetchingEnabled && info.visibleItemsInfo.isNotEmpty()) {
448             val scrollingForward = delta < 0
449 
450             val prefetchIndex =
451                 if (scrollingForward) {
452                     info.visibleItemsInfo.last().index
453                 } else {
454                     info.visibleItemsInfo.first().index
455                 }
456 
457             if (prefetchIndex == prefetchBaseIndex) {
458                 // Already prefetched based on this index
459                 return
460             }
461             prefetchBaseIndex = prefetchIndex
462 
463             val prefetchHandlesUsed = mutableSetOf<Int>()
464             var targetIndex = prefetchIndex
465             val slots = info.slots
466             val laneCount = slots.sizes.size
467             for (lane in 0 until laneCount) {
468                 val previousIndex = targetIndex
469 
470                 // find the next item for each line and prefetch if it is valid
471                 targetIndex =
472                     if (scrollingForward) {
473                         laneInfo.findNextItemIndex(previousIndex, lane)
474                     } else {
475                         laneInfo.findPreviousItemIndex(previousIndex, lane)
476                     }
477                 if (
478                     targetIndex !in (0 until info.totalItemsCount) ||
479                         targetIndex in prefetchHandlesUsed
480                 ) {
481                     break
482                 }
483 
484                 prefetchHandlesUsed += targetIndex
485                 if (targetIndex in currentItemPrefetchHandles) {
486                     continue
487                 }
488 
489                 val isFullSpan = info.spanProvider.isFullSpan(targetIndex)
490                 val slot = if (isFullSpan) 0 else lane
491                 val span = if (isFullSpan) laneCount else 1
492 
493                 val crossAxisSize =
494                     when {
495                         span == 1 -> slots.sizes[slot]
496                         else -> {
497                             val start = slots.positions[slot]
498                             val endSlot = slot + span - 1
499                             val end = slots.positions[endSlot] + slots.sizes[endSlot]
500                             end - start
501                         }
502                     }
503 
504                 val constraints =
505                     if (info.orientation == Orientation.Vertical) {
506                         Constraints.fixedWidth(crossAxisSize)
507                     } else {
508                         Constraints.fixedHeight(crossAxisSize)
509                     }
510 
511                 currentItemPrefetchHandles[targetIndex] =
512                     prefetchState.schedulePrecompositionAndPremeasure(
513                         index = targetIndex,
514                         constraints = constraints
515                     )
516             }
517 
518             clearLeftoverPrefetchHandles(prefetchHandlesUsed)
519         }
520     }
521 
clearLeftoverPrefetchHandlesnull522     private fun clearLeftoverPrefetchHandles(prefetchHandlesUsed: Set<Int>) {
523         val iterator = currentItemPrefetchHandles.iterator()
524         while (iterator.hasNext()) {
525             val entry = iterator.next()
526             if (entry.key !in prefetchHandlesUsed) {
527                 entry.value.cancel()
528                 iterator.remove()
529             }
530         }
531     }
532 
cancelPrefetchIfVisibleItemsChangednull533     private fun cancelPrefetchIfVisibleItemsChanged(info: LazyStaggeredGridLayoutInfo) {
534         val items = info.visibleItemsInfo
535         if (prefetchBaseIndex != -1 && items.isNotEmpty()) {
536             if (prefetchBaseIndex !in items.first().index..items.last().index) {
537                 prefetchBaseIndex = -1
538                 currentItemPrefetchHandles.values.forEach { it.cancel() }
539                 currentItemPrefetchHandles.clear()
540             }
541         }
542     }
543 
544     /** updates state after measure pass */
applyMeasureResultnull545     internal fun applyMeasureResult(
546         result: LazyStaggeredGridMeasureResult,
547         isLookingAhead: Boolean,
548         visibleItemsStayedTheSame: Boolean = false
549     ) {
550         if (!isLookingAhead && hasLookaheadOccurred) {
551             // If there was already a lookahead pass, record this result as Approach result
552             approachLayoutInfo = result
553         } else {
554             if (isLookingAhead) {
555                 hasLookaheadOccurred = true
556             }
557             scrollToBeConsumed -= result.consumedScroll
558             layoutInfoState.value = result
559 
560             if (visibleItemsStayedTheSame) {
561                 scrollPosition.updateScrollOffset(result.firstVisibleItemScrollOffsets)
562             } else {
563                 scrollPosition.updateFromMeasureResult(result)
564                 cancelPrefetchIfVisibleItemsChanged(result)
565             }
566             canScrollBackward = result.canScrollBackward
567             canScrollForward = result.canScrollForward
568 
569             if (isLookingAhead) {
570                 _lazyLayoutScrollDeltaBetweenPasses.updateScrollDeltaForApproach(
571                     result.scrollBackAmount,
572                     result.density,
573                     result.coroutineScope
574                 )
575             }
576             measurePassCount++
577         }
578     }
579 
580     internal val scrollDeltaBetweenPasses: Float
581         get() = _lazyLayoutScrollDeltaBetweenPasses.scrollDeltaBetweenPasses
582 
583     private val _lazyLayoutScrollDeltaBetweenPasses = LazyLayoutScrollDeltaBetweenPasses()
584 
fillNearestIndicesnull585     private fun fillNearestIndices(itemIndex: Int, laneCount: Int): IntArray {
586         val indices = IntArray(laneCount)
587         if (layoutInfoState.value.spanProvider.isFullSpan(itemIndex)) {
588             indices.fill(itemIndex)
589             return indices
590         }
591 
592         // reposition spans if needed to ensure valid indices
593         laneInfo.ensureValidIndex(itemIndex + laneCount)
594         val targetLaneIndex =
595             when (val previousLane = laneInfo.getLane(itemIndex)) {
596                 // lane was never set or contains obsolete full span (the check for full span above)
597                 Unset,
598                 FullSpan -> 0
599                 // lane was previously set, keep item to the same lane
600                 else -> {
601                     requirePrecondition(previousLane >= 0) {
602                         "Expected positive lane number, got $previousLane instead."
603                     }
604                     minOf(previousLane, laneCount)
605                 }
606             }
607 
608         // fill lanes before starting index
609         var currentItemIndex = itemIndex
610         for (lane in (targetLaneIndex - 1) downTo 0) {
611             indices[lane] = laneInfo.findPreviousItemIndex(currentItemIndex, lane)
612             if (indices[lane] == Unset) {
613                 indices.fill(-1, toIndex = lane)
614                 break
615             }
616             currentItemIndex = indices[lane]
617         }
618 
619         indices[targetLaneIndex] = itemIndex
620 
621         // fill lanes after starting index
622         currentItemIndex = itemIndex
623         for (lane in (targetLaneIndex + 1) until laneCount) {
624             indices[lane] = laneInfo.findNextItemIndex(currentItemIndex, lane)
625             currentItemIndex = indices[lane]
626         }
627 
628         return indices
629     }
630 
631     companion object {
632         /** The default implementation of [Saver] for [LazyStaggeredGridState] */
633         val Saver =
634             listSaver<LazyStaggeredGridState, IntArray>(
statenull635                 save = { state ->
636                     listOf(state.scrollPosition.indices, state.scrollPosition.scrollOffsets)
637                 },
<lambda>null638                 restore = { LazyStaggeredGridState(it[0], it[1], null) }
639             )
640     }
641 }
642