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