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.compose.foundation.ExperimentalFoundationApi
20 import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
21 import androidx.compose.foundation.lazy.layout.LazyLayoutNearestRangeState
22 import androidx.compose.foundation.lazy.layout.findIndexByKey
23 import androidx.compose.runtime.getValue
24 import androidx.compose.runtime.mutableIntStateOf
25 import androidx.compose.runtime.setValue
26 import androidx.compose.runtime.snapshots.Snapshot
27 import androidx.compose.ui.util.fastFirstOrNull
28 
29 internal class LazyStaggeredGridScrollPosition(
30     initialIndices: IntArray,
31     initialOffsets: IntArray,
32     private val fillIndices: (targetIndex: Int, laneCount: Int) -> IntArray
33 ) {
34     var indices = initialIndices
35         private set
36 
37     var index by mutableIntStateOf(calculateFirstVisibleIndex(initialIndices))
38         private set
39 
40     var scrollOffsets = initialOffsets
41         private set
42 
43     var scrollOffset by
44         mutableIntStateOf(calculateFirstVisibleScrollOffset(initialIndices, initialOffsets))
45         private set
46 
47     private fun calculateFirstVisibleIndex(indices: IntArray): Int {
48         var minIndex = Int.MAX_VALUE
49         indices.forEach { index ->
50             // index array can contain -1, indicating lane being empty (cell number > itemCount)
51             // if any of the lanes are empty, we always on 0th item index
52             if (index <= 0) return 0
53             if (minIndex > index) minIndex = index
54         }
55         return if (minIndex == Int.MAX_VALUE) 0 else minIndex
56     }
57 
58     private fun calculateFirstVisibleScrollOffset(indices: IntArray, offsets: IntArray): Int {
59         var minOffset = Int.MAX_VALUE
60         val smallestIndex = calculateFirstVisibleIndex(indices)
61         for (lane in offsets.indices) {
62             if (indices[lane] == smallestIndex) {
63                 minOffset = minOf(minOffset, offsets[lane])
64             }
65         }
66         return if (minOffset == Int.MAX_VALUE) 0 else minOffset
67     }
68 
69     private var hadFirstNotEmptyLayout = false
70 
71     /** The last know key of the item at lowest of [indices] position. */
72     private var lastKnownFirstItemKey: Any? = null
73 
74     val nearestRangeState =
75         LazyLayoutNearestRangeState(
76             initialIndices.minOrNull() ?: 0,
77             NearestItemsSlidingWindowSize,
78             NearestItemsExtraItemCount
79         )
80 
81     /** Updates the current scroll position based on the results of the last measurement. */
82     fun updateFromMeasureResult(measureResult: LazyStaggeredGridMeasureResult) {
83         val firstVisibleIndex = calculateFirstVisibleIndex(measureResult.firstVisibleItemIndices)
84 
85         lastKnownFirstItemKey =
86             measureResult.visibleItemsInfo.fastFirstOrNull { it.index == firstVisibleIndex }?.key
87         nearestRangeState.update(firstVisibleIndex)
88         // we ignore the index and offset from measureResult until we get at least one
89         // measurement with real items. otherwise the initial index and scroll passed to the
90         // state would be lost and overridden with zeros.
91         if (hadFirstNotEmptyLayout || measureResult.totalItemsCount > 0) {
92             hadFirstNotEmptyLayout = true
93             Snapshot.withoutReadObservation {
94                 update(
95                     measureResult.firstVisibleItemIndices,
96                     measureResult.firstVisibleItemScrollOffsets
97                 )
98             }
99         }
100     }
101 
102     fun updateScrollOffset(scrollOffsets: IntArray) {
103         this.scrollOffsets = scrollOffsets
104         this.scrollOffset = calculateFirstVisibleScrollOffset(indices, scrollOffsets)
105     }
106 
107     /**
108      * Updates the scroll position - the passed values will be used as a start position for
109      * composing the items during the next measure pass and will be updated by the real position
110      * calculated during the measurement. This means that there is no guarantee that exactly this
111      * index and offset will be applied as it is possible that: a) there will be no item at this
112      * index in reality b) item at this index will be smaller than the asked scrollOffset, which
113      * means we would switch to the next item c) there will be not enough items to fill the viewport
114      * after the requested index, so we would have to compose few elements before the asked index,
115      * changing the first visible item.
116      */
117     fun requestPositionAndForgetLastKnownKey(index: Int, scrollOffset: Int) {
118         val newIndices = fillIndices(index, indices.size)
119         val newOffsets = IntArray(newIndices.size) { scrollOffset }
120         update(newIndices, newOffsets)
121         nearestRangeState.update(index)
122         // clear the stored key as we have a direct request to scroll to [index] position and the
123         // next [updateScrollPositionIfTheFirstItemWasMoved] shouldn't override this.
124         lastKnownFirstItemKey = null
125     }
126 
127     /**
128      * In addition to keeping the first visible item index we also store the key of this item. When
129      * the user provided custom keys for the items this mechanism allows us to detect when there
130      * were items added or removed before our current first visible item and keep this item as the
131      * first visible one even given that its index has been changed.
132      */
133     @OptIn(ExperimentalFoundationApi::class)
134     fun updateScrollPositionIfTheFirstItemWasMoved(
135         itemProvider: LazyLayoutItemProvider,
136         indices: IntArray
137     ): IntArray {
138         val newIndex =
139             itemProvider.findIndexByKey(
140                 key = lastKnownFirstItemKey,
141                 lastKnownIndex = indices.getOrNull(0) ?: 0
142             )
143         return if (newIndex !in indices) {
144             nearestRangeState.update(newIndex)
145             val newIndices = Snapshot.withoutReadObservation { fillIndices(newIndex, indices.size) }
146             this.indices = newIndices
147             this.index = calculateFirstVisibleIndex(newIndices)
148             newIndices
149         } else {
150             indices
151         }
152     }
153 
154     private fun update(indices: IntArray, offsets: IntArray) {
155         this.indices = indices
156         this.index = calculateFirstVisibleIndex(indices)
157         this.scrollOffsets = offsets
158         this.scrollOffset = calculateFirstVisibleScrollOffset(indices, offsets)
159     }
160 }
161 
162 /**
163  * We use the idea of sliding window as an optimization, so user can scroll up to this number of
164  * items until we have to regenerate the key to index map.
165  */
166 private const val NearestItemsSlidingWindowSize = 90
167 
168 /** The minimum amount of items near the current first visible item we want to have mapping for. */
169 private const val NearestItemsExtraItemCount = 200
170