PagerMeasure.kt

/*
 * 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.fastFilter
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo
import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
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.fastForEach
import androidx.compose.ui.util.fastMaxBy
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sign

@OptIn(ExperimentalFoundationApi::class)
internal fun LazyLayoutMeasureScope.measurePager(
    pageCount: Int,
    pagerItemProvider: PagerLazyLayoutItemProvider,
    mainAxisAvailableSize: Int,
    beforeContentPadding: Int,
    afterContentPadding: Int,
    spaceBetweenPages: Int,
    firstVisiblePage: Int,
    firstVisiblePageOffset: Int,
    scrollToBeConsumed: Float,
    constraints: Constraints,
    orientation: Orientation,
    verticalAlignment: Alignment.Vertical?,
    horizontalAlignment: Alignment.Horizontal?,
    reverseLayout: Boolean,
    visualPageOffset: IntOffset,
    pageAvailableSize: Int,
    beyondBoundsPageCount: Int,
    beyondBoundsInfo: LazyListBeyondBoundsInfo,
    pinnedPages: LazyLayoutPinnedItemList,
    layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
): PagerMeasureResult {
    require(beforeContentPadding >= 0)
    require(afterContentPadding >= 0)

    val pageSizeWithSpacing = (pageAvailableSize + spaceBetweenPages).coerceAtLeast(0)

    debugLog { "Remeasuring..." }

    return if (pageCount <= 0) {
        PagerMeasureResult(
            visiblePagesInfo = emptyList(),
            pagesCount = 0,
            pageSize = pageAvailableSize,
            pageSpacing = spaceBetweenPages,
            afterContentPadding = afterContentPadding,
            orientation = orientation,
            viewportStartOffset = -beforeContentPadding,
            viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
            measureResult = layout(constraints.minWidth, constraints.minHeight) {},
            consumedScroll = 0f,
            closestPageToSnapPosition = null,
            firstVisiblePage = null,
            firstVisiblePageOffset = 0,
            reverseLayout = false,
            canScrollForward = false
        )
    } else {

        val childConstraints = Constraints(
            maxWidth = if (orientation == Orientation.Vertical) {
                constraints.maxWidth
            } else {
                pageAvailableSize
            },
            maxHeight = if (orientation != Orientation.Vertical) {
                constraints.maxHeight
            } else {
                pageAvailableSize
            }
        )

        var currentFirstPage = firstVisiblePage
        var currentFirstPageScrollOffset = firstVisiblePageOffset
        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
        }

        // represents the real amount of scroll we applied as a result of this measure pass.
        var scrollDelta = scrollToBeConsumed.roundToInt()

        // applying the whole requested scroll offset. we will figure out if we can't consume
        // all of it later
        currentFirstPageScrollOffset -= scrollDelta

        // if the current scroll offset is less than minimally possible
        if (currentFirstPage == 0 && currentFirstPageScrollOffset < 0) {
            scrollDelta += currentFirstPageScrollOffset
            currentFirstPageScrollOffset = 0
        }

        // this will contain all the measured pages representing the visible pages
        val visiblePages = mutableListOf<MeasuredPage>()

        // 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

        // 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,
                afterContentPadding = afterContentPadding,
                beforeContentPadding = beforeContentPadding,
                layoutDirection = layoutDirection,
                reverseLayout = reverseLayout,
                pageAvailableSize = pageAvailableSize
            )
            visiblePages.add(0, measuredPage)
            maxCrossAxis = maxOf(maxCrossAxis, measuredPage.crossAxisSize)
            currentFirstPageScrollOffset += pageSizeWithSpacing
            currentFirstPage = previous
        }

        // if we were scrolled backward, but there were not enough pages before. this means
        // not the whole scroll was consumed
        if (currentFirstPageScrollOffset < minOffset) {
            scrollDelta += currentFirstPageScrollOffset
            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

        // first we need to skip pages we already composed while composing backward
        visiblePages.fastForEach {
            index++
            currentMainAxisOffset += pageSizeWithSpacing
        }

        // 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,
                afterContentPadding = afterContentPadding,
                beforeContentPadding = beforeContentPadding,
                layoutDirection = layoutDirection,
                reverseLayout = reverseLayout,
                pageAvailableSize = pageAvailableSize
            )
            currentMainAxisOffset += pageSizeWithSpacing

            if (currentMainAxisOffset <= minOffset && index != pageCount - 1) {
                // this page is offscreen and will not be placed. advance firstVisiblePage
                currentFirstPage = index + 1
                currentFirstPageScrollOffset -= pageSizeWithSpacing
            } 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,
                    afterContentPadding = afterContentPadding,
                    beforeContentPadding = beforeContentPadding,
                    layoutDirection = layoutDirection,
                    reverseLayout = reverseLayout,
                    pageAvailableSize = pageAvailableSize
                )
                visiblePages.add(0, measuredPage)
                maxCrossAxis = maxOf(maxCrossAxis, measuredPage.crossAxisSize)
                currentFirstPageScrollOffset += pageSizeWithSpacing
                currentFirstPage = previousIndex
            }
            scrollDelta += toScrollBack
            if (currentFirstPageScrollOffset < 0) {
                scrollDelta += currentFirstPageScrollOffset
                currentMainAxisOffset += currentFirstPageScrollOffset
                currentFirstPageScrollOffset = 0
            }
        }

        // report the amount of pixels we consumed. scrollDelta can be smaller than
        // scrollToBeConsumed if there were not enough pages to fill the offered space or it
        // can be larger if pages were resized, or if, for example, we were previously
        // displaying the page 15, but now we have only 10 pages in total in the data set.
        val consumedScroll = if (scrollToBeConsumed.roundToInt().sign == scrollDelta.sign &&
            abs(scrollToBeConsumed.roundToInt()) >= abs(scrollDelta)
        ) {
            scrollDelta.toFloat()
        } else {
            scrollToBeConsumed
        }

        // the initial offset for pages from visiblePages list
        require(currentFirstPageScrollOffset >= 0)
        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(
            beyondBoundsInfo = beyondBoundsInfo,
            currentFirstPage = currentFirstPage,
            pagesCount = pageCount,
            beyondBoundsPageCount = beyondBoundsPageCount,
            pinnedPages = pinnedPages
        ) {
            getAndMeasure(
                index = it,
                childConstraints = childConstraints,
                pagerItemProvider = pagerItemProvider,
                visualPageOffset = visualPageOffset,
                orientation = orientation,
                horizontalAlignment = horizontalAlignment,
                verticalAlignment = verticalAlignment,
                afterContentPadding = afterContentPadding,
                beforeContentPadding = beforeContentPadding,
                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(
            beyondBoundsInfo = beyondBoundsInfo,
            visiblePages = visiblePages,
            pagesCount = pageCount,
            beyondBoundsPageCount = beyondBoundsPageCount,
            pinnedPages = pinnedPages
        ) {
            getAndMeasure(
                index = it,
                childConstraints = childConstraints,
                pagerItemProvider = pagerItemProvider,
                visualPageOffset = visualPageOffset,
                orientation = orientation,
                horizontalAlignment = horizontalAlignment,
                verticalAlignment = verticalAlignment,
                afterContentPadding = afterContentPadding,
                beforeContentPadding = beforeContentPadding,
                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 viewPortSize = if (orientation == Orientation.Vertical) layoutHeight else layoutWidth

        val closestPageToSnapPosition = visiblePagesInfo.fastMaxBy {
            -abs(
                calculateDistanceToDesiredSnapPosition(
                    viewPortSize,
                    beforeContentPadding,
                    afterContentPadding,
                    pageAvailableSize,
                    it,
                    SnapAlignmentStartToStart
                )
            )
        }

        return PagerMeasureResult(
            firstVisiblePage = firstPage,
            firstVisiblePageOffset = currentFirstPageScrollOffset,
            closestPageToSnapPosition = closestPageToSnapPosition,
            consumedScroll = consumedScroll,
            measureResult = layout(layoutWidth, layoutHeight) {
                positionedPages.fastForEach {
                    it.place(this)
                }
            },
            viewportStartOffset = -beforeContentPadding,
            viewportEndOffset = maxOffset + afterContentPadding,
            visiblePagesInfo = visiblePagesInfo,
            pagesCount = pageCount,
            reverseLayout = reverseLayout,
            orientation = orientation,
            pageSize = pageAvailableSize,
            pageSpacing = spaceBetweenPages,
            afterContentPadding = afterContentPadding,
            canScrollForward = index < pageCount || currentMainAxisOffset > maxOffset
        )
    }
}

@OptIn(ExperimentalFoundationApi::class)
private fun Density.calculateDistanceToDesiredSnapPosition(
    axisViewPortSize: Int,
    beforeContentPadding: Int,
    afterContentPadding: Int,
    pageSize: Int,
    page: PageInfo,
    positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float
): Float {
    val containerSize = axisViewPortSize - beforeContentPadding - afterContentPadding

    val desiredDistance =
        positionInLayout(containerSize.toFloat(), pageSize.toFloat())

    val itemCurrentPosition = page.offset
    return itemCurrentPosition - desiredDistance
}

@OptIn(ExperimentalFoundationApi::class)
private fun createPagesAfterList(
    beyondBoundsInfo: LazyListBeyondBoundsInfo,
    visiblePages: MutableList<MeasuredPage>,
    pagesCount: Int,
    beyondBoundsPageCount: Int,
    pinnedPages: LazyLayoutPinnedItemList,
    getAndMeasure: (Int) -> MeasuredPage
): List<MeasuredPage> {
    fun LazyListBeyondBoundsInfo.endIndex() = min(end, pagesCount - 1)

    var list: MutableList<MeasuredPage>? = null

    var end = visiblePages.last().index

    fun addPage(index: Int) {
        if (list == null) list = mutableListOf()
        requireNotNull(list).add(getAndMeasure(index))
    }

    if (beyondBoundsInfo.hasIntervals()) {
        end = maxOf(beyondBoundsInfo.endIndex(), end)
    }

    end = minOf(end + beyondBoundsPageCount, pagesCount - 1)

    for (i in visiblePages.last().index + 1..end) {
        addPage(i)
    }

    pinnedPages.fastForEach { page ->
        if (page.index in (end + 1) until pagesCount) {
            addPage(page.index)
        }
    }

    return list ?: emptyList()
}

@OptIn(ExperimentalFoundationApi::class)
private fun createPagesBeforeList(
    beyondBoundsInfo: LazyListBeyondBoundsInfo,
    currentFirstPage: Int,
    pagesCount: Int,
    beyondBoundsPageCount: Int,
    pinnedPages: LazyLayoutPinnedItemList,
    getAndMeasure: (Int) -> MeasuredPage
): List<MeasuredPage> {
    fun LazyListBeyondBoundsInfo.startIndex() = min(start, pagesCount - 1)

    var list: MutableList<MeasuredPage>? = null

    var start = currentFirstPage

    fun addPage(index: Int) {
        if (list == null) list = mutableListOf()
        requireNotNull(list).add(
            getAndMeasure(index)
        )
    }

    if (beyondBoundsInfo.hasIntervals()) {
        start = minOf(beyondBoundsInfo.startIndex(), start)
    }

    start = maxOf(0, start - beyondBoundsPageCount)

    for (i in currentFirstPage - 1 downTo start) {
        addPage(i)
    }

    pinnedPages.fastForEach { page ->
        if (page.index < start) {
            addPage(page.index)
        }
    }

    return list ?: emptyList()
}

@OptIn(ExperimentalFoundationApi::class)
private fun LazyLayoutMeasureScope.getAndMeasure(
    index: Int,
    childConstraints: Constraints,
    pagerItemProvider: PagerLazyLayoutItemProvider,
    visualPageOffset: IntOffset,
    orientation: Orientation,
    horizontalAlignment: Alignment.Horizontal?,
    verticalAlignment: Alignment.Vertical?,
    afterContentPadding: Int,
    beforeContentPadding: Int,
    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,
        afterContentPadding = afterContentPadding,
        beforeContentPadding = beforeContentPadding,
        layoutDirection = layoutDirection,
        reverseLayout = reverseLayout,
        size = pageAvailableSize,
        orientation = orientation,
        key = key
    )
}

@OptIn(ExperimentalFoundationApi::class)
private fun LazyLayoutMeasureScope.calculatePagesOffsets(
    pages: List<MeasuredPage>,
    extraPagesBefore: List<MeasuredPage>,
    extraPagesAfter: List<MeasuredPage>,
    layoutWidth: Int,
    layoutHeight: Int,
    finalMainAxisOffset: Int,
    maxOffset: Int,
    pagesScrollOffset: Int,
    orientation: Orientation,
    reverseLayout: Boolean,
    density: Density,
    spaceBetweenPages: Int,
    pageAvailableSize: Int
): MutableList<PositionedPage> {
    val pageSizeWithSpacing = (pageAvailableSize + spaceBetweenPages)
    val mainAxisLayoutSize = if (orientation == Orientation.Vertical) layoutHeight else layoutWidth
    val hasSpareSpace = finalMainAxisOffset < minOf(mainAxisLayoutSize, maxOffset)
    if (hasSpareSpace) {
        check(pagesScrollOffset == 0)
    }
    val positionedPages =
        ArrayList<PositionedPage>(pages.size + extraPagesBefore.size + extraPagesAfter.size)

    if (hasSpareSpace) {
        require(extraPagesBefore.isEmpty() && extraPagesAfter.isEmpty())

        val pagesCount = pages.size
        fun Int.reverseAware() =
            if (!reverseLayout) this else pagesCount - this - 1

        val sizes = IntArray(pagesCount) { pageAvailableSize }
        val offsets = IntArray(pagesCount) { 0 }

        val arrangement = spacedBy(pageAvailableSize.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
            }
            positionedPages.add(page.position(relativeOffset, layoutWidth, layoutHeight))
        }
    } else {
        var currentMainAxis = pagesScrollOffset
        extraPagesBefore.fastForEach {
            currentMainAxis -= pageSizeWithSpacing
            positionedPages.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
        }

        currentMainAxis = pagesScrollOffset
        pages.fastForEach {
            positionedPages.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
            currentMainAxis += pageSizeWithSpacing
        }

        extraPagesAfter.fastForEach {
            positionedPages.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
            currentMainAxis += pageSizeWithSpacing
        }
    }
    return positionedPages
}

private const val DEBUG = false
private inline fun debugLog(generateMsg: () -> String) {
    if (DEBUG) {
        println("PagerMeasure: ${generateMsg()}")
    }
}