commonMain.androidx.compose.foundation.pager.PagerMeasure.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of foundation Show documentation
Show all versions of foundation Show documentation
Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers
The newest version!
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.pager
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.snapping.SnapPosition
import androidx.compose.foundation.gestures.snapping.calculateDistanceToDesiredSnapPosition
import androidx.compose.foundation.internal.checkPrecondition
import androidx.compose.foundation.internal.requirePrecondition
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMaxBy
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyLayoutMeasureScope.measurePager(
pageCount: Int,
pagerItemProvider: PagerLazyLayoutItemProvider,
mainAxisAvailableSize: Int,
beforeContentPadding: Int,
afterContentPadding: Int,
spaceBetweenPages: Int,
currentPage: Int,
currentPageOffset: Int,
constraints: Constraints,
orientation: Orientation,
verticalAlignment: Alignment.Vertical?,
horizontalAlignment: Alignment.Horizontal?,
reverseLayout: Boolean,
visualPageOffset: IntOffset,
pageAvailableSize: Int,
beyondViewportPageCount: Int,
pinnedPages: List,
snapPosition: SnapPosition,
placementScopeInvalidator: ObservableScopeInvalidator,
coroutineScope: CoroutineScope,
layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
): PagerMeasureResult {
requirePrecondition(beforeContentPadding >= 0) { "negative beforeContentPadding" }
requirePrecondition(afterContentPadding >= 0) { "negative afterContentPadding" }
val pageSizeWithSpacing = (pageAvailableSize + spaceBetweenPages).coerceAtLeast(0)
debugLog {
"Starting Measure Pass..." +
"\n CurrentPage = $currentPage" +
"\n CurrentPageOffset = $currentPageOffset" +
"\n SnapPosition = $snapPosition"
}
return if (pageCount <= 0) {
PagerMeasureResult(
visiblePagesInfo = emptyList(),
pageSize = pageAvailableSize,
pageSpacing = spaceBetweenPages,
afterContentPadding = afterContentPadding,
orientation = orientation,
viewportStartOffset = -beforeContentPadding,
viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
measureResult = layout(constraints.minWidth, constraints.minHeight) {},
firstVisiblePage = null,
firstVisiblePageScrollOffset = 0,
reverseLayout = false,
beyondViewportPageCount = beyondViewportPageCount,
canScrollForward = false,
currentPage = null,
currentPageOffsetFraction = 0.0f,
snapPosition = snapPosition,
remeasureNeeded = false,
coroutineScope = coroutineScope
)
} else {
val childConstraints =
Constraints(
maxWidth =
if (orientation == Orientation.Vertical) {
constraints.maxWidth
} else {
pageAvailableSize
},
maxHeight =
if (orientation != Orientation.Vertical) {
constraints.maxHeight
} else {
pageAvailableSize
}
)
var firstVisiblePage = currentPage
var firstVisiblePageOffset = currentPageOffset
// figure out the first visible page and the firstVisiblePageOffset based on current page
// The offset by the scroll event has already been applied to currentPageOffset
while (firstVisiblePage > 0 && firstVisiblePageOffset > 0) {
firstVisiblePage--
firstVisiblePageOffset -= pageSizeWithSpacing
}
// the scroll offset is opposite sign to the actual offset
val firstVisiblePageScrollOffset = firstVisiblePageOffset * -1
var currentFirstPage = firstVisiblePage
var currentFirstPageScrollOffset = firstVisiblePageScrollOffset
if (currentFirstPage >= pageCount) {
// the data set has been updated and now we have less pages that we were
// scrolled to before
currentFirstPage = pageCount - 1
currentFirstPageScrollOffset = 0
}
debugLog {
"Calculated Info:" +
"\n FirstVisiblePage=$firstVisiblePage" +
"\n firstVisiblePageScrollOffset=$firstVisiblePageScrollOffset"
}
// this will contain all the measured pages representing the visible pages
val visiblePages = ArrayDeque()
// define min and max offsets
val minOffset = -beforeContentPadding + if (spaceBetweenPages < 0) spaceBetweenPages else 0
val maxOffset = mainAxisAvailableSize
// include the start padding so we compose pages in the padding area and neutralise page
// spacing (if the spacing is negative this will make sure the previous page is composed)
// before starting scrolling forward we will remove it back
currentFirstPageScrollOffset += minOffset
// max of cross axis sizes of all visible pages
var maxCrossAxis = 0
debugLog { "Composing Backwards" }
// we had scrolled backward or we compose pages in the start padding area, which means
// pages before current firstPageScrollOffset should be visible. compose them and update
// firstPageScrollOffset
while (currentFirstPageScrollOffset < 0 && currentFirstPage > 0) {
val previous = currentFirstPage - 1
val measuredPage =
getAndMeasure(
index = previous,
childConstraints = childConstraints,
pagerItemProvider = pagerItemProvider,
visualPageOffset = visualPageOffset,
orientation = orientation,
horizontalAlignment = horizontalAlignment,
verticalAlignment = verticalAlignment,
layoutDirection = layoutDirection,
reverseLayout = reverseLayout,
pageAvailableSize = pageAvailableSize
)
debugLog { "Composed Page=$previous" }
visiblePages.add(0, measuredPage)
maxCrossAxis = maxOf(maxCrossAxis, measuredPage.crossAxisSize)
currentFirstPageScrollOffset += pageSizeWithSpacing
currentFirstPage = previous
}
if (currentFirstPageScrollOffset < minOffset) {
currentFirstPageScrollOffset = minOffset
}
// neutralize previously added padding as we stopped filling the before content padding
currentFirstPageScrollOffset -= minOffset
var index = currentFirstPage
val maxMainAxis = (maxOffset + afterContentPadding).coerceAtLeast(0)
var currentMainAxisOffset = -currentFirstPageScrollOffset
// will be set to true if we composed some items only to know their size and apply scroll,
// while in the end this item will not end up in the visible viewport. we will need an
// extra remeasure in order to dispose such items.
var remeasureNeeded = false
// first we need to skip pages we already composed while composing backward
var indexInVisibleItems = 0
while (indexInVisibleItems < visiblePages.size) {
if (currentMainAxisOffset >= maxMainAxis) {
// this item is out of the bounds and will not be visible.
visiblePages.removeAt(indexInVisibleItems)
remeasureNeeded = true
} else {
index++
currentMainAxisOffset += pageSizeWithSpacing
indexInVisibleItems++
}
}
debugLog { "Composing Forward Starting at Index=$index" }
// then composing visible pages forward until we fill the whole viewport.
// we want to have at least one page in visiblePages even if in fact all the pages are
// offscreen, this can happen if the content padding is larger than the available size.
while (
index < pageCount &&
(currentMainAxisOffset < maxMainAxis ||
currentMainAxisOffset <= 0 || // filling beforeContentPadding area
visiblePages.isEmpty())
) {
val measuredPage =
getAndMeasure(
index = index,
childConstraints = childConstraints,
pagerItemProvider = pagerItemProvider,
visualPageOffset = visualPageOffset,
orientation = orientation,
horizontalAlignment = horizontalAlignment,
verticalAlignment = verticalAlignment,
layoutDirection = layoutDirection,
reverseLayout = reverseLayout,
pageAvailableSize = pageAvailableSize
)
debugLog { "Composed Page=$index at $currentFirstPageScrollOffset" }
// do not add space to the last page
currentMainAxisOffset +=
if (index == pageCount - 1) {
pageAvailableSize
} else {
pageSizeWithSpacing
}
if (currentMainAxisOffset <= minOffset && index != pageCount - 1) {
// this page is offscreen and will not be visible. advance currentFirstPage
currentFirstPage = index + 1
currentFirstPageScrollOffset -= pageSizeWithSpacing
remeasureNeeded = true
} else {
maxCrossAxis = maxOf(maxCrossAxis, measuredPage.crossAxisSize)
visiblePages.add(measuredPage)
}
index++
}
// we didn't fill the whole viewport with pages starting from firstVisiblePage.
// lets try to scroll back if we have enough pages before firstVisiblePage.
if (currentMainAxisOffset < maxOffset) {
val toScrollBack = maxOffset - currentMainAxisOffset
currentFirstPageScrollOffset -= toScrollBack
currentMainAxisOffset += toScrollBack
while (currentFirstPageScrollOffset < beforeContentPadding && currentFirstPage > 0) {
val previousIndex = currentFirstPage - 1
val measuredPage =
getAndMeasure(
index = previousIndex,
childConstraints = childConstraints,
pagerItemProvider = pagerItemProvider,
visualPageOffset = visualPageOffset,
orientation = orientation,
horizontalAlignment = horizontalAlignment,
verticalAlignment = verticalAlignment,
layoutDirection = layoutDirection,
reverseLayout = reverseLayout,
pageAvailableSize = pageAvailableSize
)
visiblePages.add(0, measuredPage)
maxCrossAxis = maxOf(maxCrossAxis, measuredPage.crossAxisSize)
currentFirstPageScrollOffset += pageSizeWithSpacing
currentFirstPage = previousIndex
}
if (currentFirstPageScrollOffset < 0) {
currentMainAxisOffset += currentFirstPageScrollOffset
currentFirstPageScrollOffset = 0
}
}
// the initial offset for pages from visiblePages list
requirePrecondition(currentFirstPageScrollOffset >= 0) {
"invalid currentFirstPageScrollOffset"
}
val visiblePagesScrollOffset = -currentFirstPageScrollOffset
var firstPage = visiblePages.first()
// even if we compose pages to fill before content padding we should ignore pages fully
// located there for the state's scroll position calculation (first page + first offset)
if (beforeContentPadding > 0 || spaceBetweenPages < 0) {
for (i in visiblePages.indices) {
val size = pageSizeWithSpacing
if (
currentFirstPageScrollOffset != 0 &&
size <= currentFirstPageScrollOffset &&
i != visiblePages.lastIndex
) {
currentFirstPageScrollOffset -= size
firstPage = visiblePages[i + 1]
} else {
break
}
}
}
// Compose extra pages before
val extraPagesBefore =
createPagesBeforeList(
currentFirstPage = currentFirstPage,
beyondViewportPageCount = beyondViewportPageCount,
pinnedPages = pinnedPages
) {
getAndMeasure(
index = it,
childConstraints = childConstraints,
pagerItemProvider = pagerItemProvider,
visualPageOffset = visualPageOffset,
orientation = orientation,
horizontalAlignment = horizontalAlignment,
verticalAlignment = verticalAlignment,
layoutDirection = layoutDirection,
reverseLayout = reverseLayout,
pageAvailableSize = pageAvailableSize
)
}
// Update maxCrossAxis with extra pages
extraPagesBefore.fastForEach { maxCrossAxis = maxOf(maxCrossAxis, it.crossAxisSize) }
// Compose pages after last page
val extraPagesAfter =
createPagesAfterList(
currentLastPage = visiblePages.last().index,
pagesCount = pageCount,
beyondViewportPageCount = beyondViewportPageCount,
pinnedPages = pinnedPages
) {
getAndMeasure(
index = it,
childConstraints = childConstraints,
pagerItemProvider = pagerItemProvider,
visualPageOffset = visualPageOffset,
orientation = orientation,
horizontalAlignment = horizontalAlignment,
verticalAlignment = verticalAlignment,
layoutDirection = layoutDirection,
reverseLayout = reverseLayout,
pageAvailableSize = pageAvailableSize
)
}
// Update maxCrossAxis with extra pages
extraPagesAfter.fastForEach { maxCrossAxis = maxOf(maxCrossAxis, it.crossAxisSize) }
val noExtraPages =
firstPage == visiblePages.first() &&
extraPagesBefore.isEmpty() &&
extraPagesAfter.isEmpty()
val layoutWidth =
constraints.constrainWidth(
if (orientation == Orientation.Vertical) maxCrossAxis else currentMainAxisOffset
)
val layoutHeight =
constraints.constrainHeight(
if (orientation == Orientation.Vertical) currentMainAxisOffset else maxCrossAxis
)
val positionedPages =
calculatePagesOffsets(
pages = visiblePages,
extraPagesBefore = extraPagesBefore,
extraPagesAfter = extraPagesAfter,
layoutWidth = layoutWidth,
layoutHeight = layoutHeight,
finalMainAxisOffset = currentMainAxisOffset,
maxOffset = maxOffset,
pagesScrollOffset = visiblePagesScrollOffset,
orientation = orientation,
reverseLayout = reverseLayout,
density = this,
pageAvailableSize = pageAvailableSize,
spaceBetweenPages = spaceBetweenPages
)
val visiblePagesInfo =
if (noExtraPages) positionedPages
else
positionedPages.fastFilter {
(it.index >= visiblePages.first().index &&
it.index <= visiblePages.last().index)
}
val positionedPagesBefore =
if (extraPagesBefore.isEmpty()) emptyList()
else positionedPages.fastFilter { it.index < visiblePages.first().index }
val positionedPagesAfter =
if (extraPagesAfter.isEmpty()) emptyList()
else positionedPages.fastFilter { it.index > visiblePages.last().index }
val layoutSize = mainAxisAvailableSize + beforeContentPadding + afterContentPadding
val newCurrentPage =
calculateNewCurrentPage(
layoutSize,
visiblePagesInfo,
beforeContentPadding,
afterContentPadding,
pageSizeWithSpacing,
snapPosition,
pageCount
)
val snapOffset =
snapPosition.position(
layoutSize,
pageAvailableSize,
beforeContentPadding,
afterContentPadding,
newCurrentPage?.index ?: 0,
pageCount
)
val currentPagePositionOffset = (newCurrentPage?.offset ?: 0)
val currentPageOffsetFraction =
if (pageSizeWithSpacing == 0) {
0.0f
} else {
((snapOffset - currentPagePositionOffset) / (pageSizeWithSpacing.toFloat()))
.coerceIn(MinPageOffset, MaxPageOffset)
}
debugLog {
"Finished Measure Pass" +
"\n Final currentPage=${newCurrentPage?.index} " +
"\n Final currentPageScrollOffset=$currentPagePositionOffset" +
"\n Final currentPageScrollOffsetFraction=$currentPageOffsetFraction"
}
return PagerMeasureResult(
firstVisiblePage = firstPage,
firstVisiblePageScrollOffset = currentFirstPageScrollOffset,
measureResult =
layout(layoutWidth, layoutHeight) {
// Tagging as motion frame of reference placement, meaning the placement
// contains scrolling. This allows the consumer of this placement offset to
// differentiate this offset vs. offsets from structural changes. Generally
// speaking, this signals a preference to directly apply changes rather than
// animating, to avoid a chasing effect to scrolling.
withMotionFrameOfReferencePlacement {
positionedPages.fastForEach { it.place(this) }
}
// we attach it during the placement so PagerState can trigger re-placement
placementScopeInvalidator.attachToScope()
},
viewportStartOffset = -beforeContentPadding,
viewportEndOffset = maxOffset + afterContentPadding,
visiblePagesInfo = visiblePagesInfo,
reverseLayout = reverseLayout,
orientation = orientation,
pageSize = pageAvailableSize,
pageSpacing = spaceBetweenPages,
afterContentPadding = afterContentPadding,
beyondViewportPageCount = beyondViewportPageCount,
canScrollForward = index < pageCount || currentMainAxisOffset > maxOffset,
currentPage = newCurrentPage,
currentPageOffsetFraction = currentPageOffsetFraction,
snapPosition = snapPosition,
remeasureNeeded = remeasureNeeded,
extraPagesBefore = positionedPagesBefore,
extraPagesAfter = positionedPagesAfter,
coroutineScope = coroutineScope
)
}
}
private fun createPagesAfterList(
currentLastPage: Int,
pagesCount: Int,
beyondViewportPageCount: Int,
pinnedPages: List,
getAndMeasure: (Int) -> MeasuredPage
): List {
var list: MutableList? = null
val end = minOf(currentLastPage + beyondViewportPageCount, pagesCount - 1)
for (i in currentLastPage + 1..end) {
if (list == null) list = mutableListOf()
list.add(getAndMeasure(i))
}
pinnedPages.fastForEach { pageIndex ->
if (pageIndex in (end + 1) until pagesCount) {
if (list == null) list = mutableListOf()
list?.add(getAndMeasure(pageIndex))
}
}
return list ?: emptyList()
}
private fun createPagesBeforeList(
currentFirstPage: Int,
beyondViewportPageCount: Int,
pinnedPages: List,
getAndMeasure: (Int) -> MeasuredPage
): List {
var list: MutableList? = null
val start = maxOf(0, currentFirstPage - beyondViewportPageCount)
for (i in currentFirstPage - 1 downTo start) {
if (list == null) list = mutableListOf()
list.add(getAndMeasure(i))
}
pinnedPages.fastForEach { pageIndex ->
if (pageIndex < start) {
if (list == null) list = mutableListOf()
list?.add(getAndMeasure(pageIndex))
}
}
return list ?: emptyList()
}
private fun calculateNewCurrentPage(
viewportSize: Int,
visiblePagesInfo: List,
beforeContentPadding: Int,
afterContentPadding: Int,
itemSize: Int,
snapPosition: SnapPosition,
pageCount: Int
): MeasuredPage? {
return visiblePagesInfo.fastMaxBy {
-abs(
calculateDistanceToDesiredSnapPosition(
mainAxisViewPortSize = viewportSize,
beforeContentPadding = beforeContentPadding,
afterContentPadding = afterContentPadding,
itemSize = itemSize,
itemOffset = it.offset,
itemIndex = it.index,
snapPosition = snapPosition,
itemCount = pageCount
)
)
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyLayoutMeasureScope.getAndMeasure(
index: Int,
childConstraints: Constraints,
pagerItemProvider: PagerLazyLayoutItemProvider,
visualPageOffset: IntOffset,
orientation: Orientation,
horizontalAlignment: Alignment.Horizontal?,
verticalAlignment: Alignment.Vertical?,
layoutDirection: LayoutDirection,
reverseLayout: Boolean,
pageAvailableSize: Int
): MeasuredPage {
val key = pagerItemProvider.getKey(index)
val placeable = measure(index, childConstraints)
return MeasuredPage(
index = index,
placeables = placeable,
visualOffset = visualPageOffset,
horizontalAlignment = horizontalAlignment,
verticalAlignment = verticalAlignment,
layoutDirection = layoutDirection,
reverseLayout = reverseLayout,
size = pageAvailableSize,
orientation = orientation,
key = key
)
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyLayoutMeasureScope.calculatePagesOffsets(
pages: List,
extraPagesBefore: List,
extraPagesAfter: List,
layoutWidth: Int,
layoutHeight: Int,
finalMainAxisOffset: Int,
maxOffset: Int,
pagesScrollOffset: Int,
orientation: Orientation,
reverseLayout: Boolean,
density: Density,
spaceBetweenPages: Int,
pageAvailableSize: Int
): MutableList {
val pageSizeWithSpacing = (pageAvailableSize + spaceBetweenPages)
val mainAxisLayoutSize = if (orientation == Orientation.Vertical) layoutHeight else layoutWidth
val hasSpareSpace = finalMainAxisOffset < minOf(mainAxisLayoutSize, maxOffset)
if (hasSpareSpace) {
checkPrecondition(pagesScrollOffset == 0) {
"non-zero pagesScrollOffset=$pagesScrollOffset"
}
}
val positionedPages =
ArrayList(pages.size + extraPagesBefore.size + extraPagesAfter.size)
if (hasSpareSpace) {
requirePrecondition(extraPagesBefore.isEmpty() && extraPagesAfter.isEmpty()) {
"No extra pages"
}
val pagesCount = pages.size
fun Int.reverseAware() = if (!reverseLayout) this else pagesCount - this - 1
val sizes = IntArray(pagesCount) { pageAvailableSize }
val offsets = IntArray(pagesCount)
val arrangement = spacedBy(spaceBetweenPages.toDp())
if (orientation == Orientation.Vertical) {
with(arrangement) { density.arrange(mainAxisLayoutSize, sizes, offsets) }
} else {
with(arrangement) {
// Enforces Ltr layout direction as it is mirrored with placeRelative later.
density.arrange(mainAxisLayoutSize, sizes, LayoutDirection.Ltr, offsets)
}
}
val reverseAwareOffsetIndices =
if (!reverseLayout) offsets.indices else offsets.indices.reversed()
for (index in reverseAwareOffsetIndices) {
val absoluteOffset = offsets[index]
// when reverseLayout == true, offsets are stored in the reversed order to pages
val page = pages[index.reverseAware()]
val relativeOffset =
if (reverseLayout) {
// inverse offset to align with scroll direction for positioning
mainAxisLayoutSize - absoluteOffset - page.size
} else {
absoluteOffset
}
page.position(relativeOffset, layoutWidth, layoutHeight)
positionedPages.add(page)
}
} else {
var currentMainAxis = pagesScrollOffset
extraPagesBefore.fastForEach {
currentMainAxis -= pageSizeWithSpacing
it.position(currentMainAxis, layoutWidth, layoutHeight)
positionedPages.add(it)
}
currentMainAxis = pagesScrollOffset
pages.fastForEach {
it.position(currentMainAxis, layoutWidth, layoutHeight)
positionedPages.add(it)
currentMainAxis += pageSizeWithSpacing
}
extraPagesAfter.fastForEach {
it.position(currentMainAxis, layoutWidth, layoutHeight)
positionedPages.add(it)
currentMainAxis += pageSizeWithSpacing
}
}
return positionedPages
}
internal const val MinPageOffset = -0.5f
internal const val MaxPageOffset = 0.5f
private inline fun debugLog(generateMsg: () -> String) {
if (PagerDebugConfig.MeasureLogic) {
println("PagerMeasure: ${generateMsg()}")
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy