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