1 /*
2 * Copyright 2023 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.pager
18
19 import androidx.compose.foundation.ExperimentalFoundationApi
20 import androidx.compose.foundation.lazy.layout.LazyLayoutNearestRangeState
21 import androidx.compose.foundation.lazy.layout.findIndexByKey
22 import androidx.compose.runtime.getValue
23 import androidx.compose.runtime.mutableFloatStateOf
24 import androidx.compose.runtime.mutableIntStateOf
25 import androidx.compose.runtime.setValue
26 import kotlin.math.roundToLong
27
28 /**
29 * Contains the current scroll position represented by the first visible page and the first visible
30 * page scroll offset.
31 */
32 internal class PagerScrollPosition(
33 currentPage: Int = 0,
34 currentPageOffsetFraction: Float = 0.0f,
35 val state: PagerState
36 ) {
37 var currentPage by mutableIntStateOf(currentPage)
38 private set
39
40 var currentPageOffsetFraction by mutableFloatStateOf(currentPageOffsetFraction)
41 private set
42
43 private var hadFirstNotEmptyLayout = false
44
45 /** The last know key of the page at [currentPage] position. */
46 private var lastKnownCurrentPageKey: Any? = null
47
48 val nearestRangeState =
49 LazyLayoutNearestRangeState(
50 currentPage,
51 NearestItemsSlidingWindowSize,
52 NearestItemsExtraItemCount
53 )
54
55 /** Updates the current scroll position based on the results of the last measurement. */
updateFromMeasureResultnull56 fun updateFromMeasureResult(measureResult: PagerMeasureResult) {
57 lastKnownCurrentPageKey = measureResult.currentPage?.key
58 // we ignore the index and offset from measureResult until we get at least one
59 // measurement with real pages. otherwise the initial index and scroll passed to the
60 // state would be lost and overridden with zeros.
61 if (hadFirstNotEmptyLayout || measureResult.visiblePagesInfo.isNotEmpty()) {
62 hadFirstNotEmptyLayout = true
63
64 update(measureResult.currentPage?.index ?: 0, measureResult.currentPageOffsetFraction)
65 }
66 }
67
68 /**
69 * Updates the scroll position - the passed values will be used as a start position for
70 * composing the pages during the next measure pass and will be updated by the real position
71 * calculated during the measurement. This means that there is no guarantee that exactly this
72 * index and offset will be applied as it is possible that: a) there will be no page at this
73 * index in reality b) page at this index will be smaller than the asked scrollOffset, which
74 * means we would switch to the next page c) there will be not enough pages to fill the viewport
75 * after the requested index, so we would have to compose few elements before the asked index,
76 * changing the first visible page.
77 */
requestPositionAndForgetLastKnownKeynull78 fun requestPositionAndForgetLastKnownKey(index: Int, offsetFraction: Float) {
79 update(index, offsetFraction)
80 // clear the stored key as we have a direct request to scroll to [index] position and the
81 // next [checkIfFirstVisibleItemWasMoved] shouldn't override this.
82 lastKnownCurrentPageKey = null
83 }
84
85 @OptIn(ExperimentalFoundationApi::class)
matchPageWithKeynull86 fun matchPageWithKey(itemProvider: PagerLazyLayoutItemProvider, index: Int): Int {
87 val newIndex = itemProvider.findIndexByKey(lastKnownCurrentPageKey, index)
88 if (index != newIndex) {
89 currentPage = newIndex
90 nearestRangeState.update(index)
91 }
92 return newIndex
93 }
94
updatenull95 private fun update(page: Int, offsetFraction: Float) {
96 currentPage = page
97 nearestRangeState.update(page)
98 currentPageOffsetFraction = offsetFraction
99 }
100
updateCurrentPageOffsetFractionnull101 fun updateCurrentPageOffsetFraction(offsetFraction: Float) {
102 currentPageOffsetFraction = offsetFraction
103 }
104
applyScrollDeltanull105 fun applyScrollDelta(delta: Int) {
106 debugLog { "Applying Delta=$delta" }
107 val fractionUpdate =
108 if (state.pageSizeWithSpacing == 0) {
109 0.0f
110 } else {
111 delta / state.pageSizeWithSpacing.toFloat()
112 }
113 currentPageOffsetFraction += fractionUpdate
114 }
115 }
116
117 /**
118 * We use the idea of sliding window as an optimization, so user can scroll up to this number of
119 * items until we have to regenerate the key to index map.
120 */
121 internal const val NearestItemsSlidingWindowSize = 30
122
123 /** The minimum amount of items near the current first visible item we want to have mapping for. */
124 internal const val NearestItemsExtraItemCount = 100
125
debugLognull126 private inline fun debugLog(generateMsg: () -> String) {
127 if (PagerDebugConfig.ScrollPosition) {
128 println("PagerScrollPosition: ${generateMsg()}")
129 }
130 }
131
currentAbsoluteScrollOffsetnull132 internal fun PagerState.currentAbsoluteScrollOffset(): Long {
133 val currentPageOffset = currentPage.toLong() * pageSizeWithSpacing
134 val offsetFraction = (currentPageOffsetFraction * pageSizeWithSpacing).roundToLong()
135 return currentPageOffset + offsetFraction
136 }
137