All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.gestures.snapping.PagerSnapLayoutInfoProvider.kt Maven / Gradle / Ivy

/*
 * Copyright 2024 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.gestures.snapping

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.internal.checkPrecondition
import androidx.compose.foundation.pager.PagerDebugConfig
import androidx.compose.foundation.pager.PagerLayoutInfo
import androidx.compose.foundation.pager.PagerSnapDistance
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.mainAxisViewportSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.util.fastForEach
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sign

internal fun SnapLayoutInfoProvider(
    pagerState: PagerState,
    pagerSnapDistance: PagerSnapDistance,
    calculateFinalSnappingBound: (Float, Float, Float) -> Float
): SnapLayoutInfoProvider {
    return object : SnapLayoutInfoProvider {
        val layoutInfo: PagerLayoutInfo
            get() = pagerState.layoutInfo

        fun Float.isValidDistance(): Boolean {
            return this != Float.POSITIVE_INFINITY && this != Float.NEGATIVE_INFINITY
        }

        override fun calculateSnapOffset(velocity: Float): Float {
            val snapPosition = pagerState.layoutInfo.snapPosition
            val (lowerBoundOffset, upperBoundOffset) =
                searchForSnappingBounds(snapPosition, velocity)

            val finalDistance =
                calculateFinalSnappingBound(velocity, lowerBoundOffset, upperBoundOffset)

            checkPrecondition(
                finalDistance == lowerBoundOffset ||
                    finalDistance == upperBoundOffset ||
                    finalDistance == 0.0f
            ) {
                "Final Snapping Offset Should Be one of $lowerBoundOffset, $upperBoundOffset or 0.0"
            }

            debugLog { "Snapping to=$finalDistance" }

            return if (finalDistance.isValidDistance()) {
                finalDistance
            } else {
                0f
            }
        }

        override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float {
            debugLog { "Approach Velocity=$velocity" }
            val effectivePageSizePx = pagerState.pageSize + pagerState.pageSpacing

            // Page Size is Zero, do not proceed.
            if (effectivePageSizePx == 0) return 0f

            // given this velocity, where can I go with a decay animation.
            val animationOffsetPx = decayOffset

            val startPage =
                if (velocity < 0) {
                    pagerState.firstVisiblePage + 1
                } else {
                    pagerState.firstVisiblePage
                }

            debugLog {
                "\nAnimation Offset=$animationOffsetPx " +
                    "\nFling Start Page=$startPage " +
                    "\nEffective Page Size=$effectivePageSizePx"
            }

            // How many pages fit in the animation offset.
            val pagesInAnimationOffset = animationOffsetPx / effectivePageSizePx

            debugLog { "Pages in Animation Offset=$pagesInAnimationOffset" }

            // Decide where this will get us in terms of target page.
            val targetPage =
                (startPage + pagesInAnimationOffset.toInt()).coerceIn(0, pagerState.pageCount)

            debugLog { "Fling Target Page=$targetPage" }

            // Apply the snap distance suggestion.
            val correctedTargetPage =
                pagerSnapDistance
                    .calculateTargetPage(
                        startPage,
                        targetPage,
                        velocity,
                        pagerState.pageSize,
                        pagerState.pageSpacing
                    )
                    .coerceIn(0, pagerState.pageCount)

            debugLog { "Fling Corrected Target Page=$correctedTargetPage" }

            // Calculate the offset with the new target page. The offset is the amount of
            // pixels between the start page and the new target page.
            val proposedFlingOffset = (correctedTargetPage - startPage) * effectivePageSizePx

            debugLog { "Proposed Fling Approach Offset=$proposedFlingOffset" }

            // We'd like the approach animation to finish right before the last page so we can
            // use a snapping animation for the rest.
            val flingApproachOffsetPx =
                (abs(proposedFlingOffset) - effectivePageSizePx).coerceAtLeast(0)

            // Apply the correct sign.
            return if (flingApproachOffsetPx == 0) {
                    flingApproachOffsetPx.toFloat()
                } else {
                    flingApproachOffsetPx * velocity.sign
                }
                .also { debugLog { "Fling Approach Offset=$it" } }
        }

        private fun searchForSnappingBounds(
            snapPosition: SnapPosition,
            velocity: Float
        ): Pair {
            debugLog { "Calculating Snapping Bounds" }
            var lowerBoundOffset = Float.NEGATIVE_INFINITY
            var upperBoundOffset = Float.POSITIVE_INFINITY

            layoutInfo.visiblePagesInfo.fastForEach { page ->
                val offset =
                    calculateDistanceToDesiredSnapPosition(
                        mainAxisViewPortSize = layoutInfo.mainAxisViewportSize,
                        beforeContentPadding = layoutInfo.beforeContentPadding,
                        afterContentPadding = layoutInfo.afterContentPadding,
                        itemSize = layoutInfo.pageSize,
                        itemOffset = page.offset,
                        itemIndex = page.index,
                        snapPosition = snapPosition,
                        itemCount = pagerState.pageCount
                    )

                // Find page that is closest to the snap position, but before it
                if (offset <= 0 && offset > lowerBoundOffset) {
                    lowerBoundOffset = offset
                }

                // Find page that is closest to the snap position, but after it
                if (offset >= 0 && offset < upperBoundOffset) {
                    upperBoundOffset = offset
                }
            }

            // If any of the bounds is unavailable, use the other.
            if (lowerBoundOffset == Float.NEGATIVE_INFINITY) {
                lowerBoundOffset = upperBoundOffset
            }

            if (upperBoundOffset == Float.POSITIVE_INFINITY) {
                upperBoundOffset = lowerBoundOffset
            }

            // Don't move if we are at the bounds

            if (!pagerState.canScrollForward) {
                upperBoundOffset = 0.0f
                // If we can not scroll forward but are trying to move towards the bound, set both
                // bounds to 0 as we don't want to move
                if (pagerState.isScrollingForward(velocity)) {
                    lowerBoundOffset = 0.0f
                }
            }

            if (!pagerState.canScrollBackward) {
                lowerBoundOffset = 0.0f
                // If we can not scroll backward but are trying to move towards the bound, set both
                // bounds to 0 as we don't want to move
                if (!pagerState.isScrollingForward(velocity)) {
                    upperBoundOffset = 0.0f
                }
            }
            return lowerBoundOffset to upperBoundOffset
        }
    }
}

private fun PagerState.isLtrDragging() = dragGestureDelta() > 0

private fun PagerState.isScrollingForward(velocity: Float): Boolean {
    val reverseScrollDirection = layoutInfo.reverseLayout
    val isForward = if (isNotGestureAction()) {
        velocity < 0
    } else {
        isLtrDragging()
    }
    return (isForward && reverseScrollDirection ||
        !isForward && !reverseScrollDirection)
}

private fun PagerState.dragGestureDelta() =
    if (layoutInfo.orientation == Orientation.Horizontal) {
        upDownDifference.x
    } else {
        upDownDifference.y
    }

private inline fun debugLog(generateMsg: () -> String) {
    if (PagerDebugConfig.PagerSnapLayoutInfoProvider) {
        println("PagerSnapLayoutInfoProvider: ${generateMsg()}")
    }
}

/**
 * Given two possible bounds that this Pager can settle in represented by [lowerBoundOffset] and
 * [upperBoundOffset], this function will decide which one of them it will settle to.
 */
internal fun calculateFinalSnappingBound(
    pagerState: PagerState,
    layoutDirection: LayoutDirection,
    snapPositionalThreshold: Float,
    flingVelocity: Float,
    lowerBoundOffset: Float,
    upperBoundOffset: Float
): Float {

    val isScrollingForward = pagerState.isScrollingForward(flingVelocity)
    val isForward =
        if (pagerState.layoutInfo.orientation == Orientation.Vertical) {
            isScrollingForward
        } else {
            if (layoutDirection == LayoutDirection.Ltr) {
                isScrollingForward
            } else {
                !isScrollingForward
            }
        }
    debugLog {
        "isScrollingForward=${isScrollingForward} " +
            "isForward=$isForward " +
            "layoutDirection=$layoutDirection"
    }
    // how many pages have I scrolled using a drag gesture.
    val pageSize = pagerState.layoutInfo.pageSize
    val offsetFromSnappedPosition =
        if (pageSize == 0) {
            0f
        } else {
            pagerState.dragGestureDelta() / pageSize.toFloat()
        }

    // we're only interested in the decimal part of the offset.
    val offsetFromSnappedPositionOverflow =
        offsetFromSnappedPosition - offsetFromSnappedPosition.toInt().toFloat()

    // If the velocity is not high, use the positional threshold to decide where to go.
    // This is applicable mainly when the user scrolls and lets go without flinging.
    val finalSnappingItem = with(pagerState.density) { calculateFinalSnappingItem(flingVelocity) }

    debugLog {
        "\nfinalSnappingItem=$finalSnappingItem" +
            "\nlower=$lowerBoundOffset" +
            "\nupper=$upperBoundOffset"
    }

    return when (finalSnappingItem) {
        FinalSnappingItem.ClosestItem -> {
            if (offsetFromSnappedPositionOverflow.absoluteValue > snapPositionalThreshold) {
                // If we crossed the threshold, go to the next bound
                debugLog { "Crossed Snap Positional Threshold" }
                if (isForward) upperBoundOffset else lowerBoundOffset
            } else {
                // if we haven't crossed the threshold. but scrolled minimally, we should
                // bound to the previous bound
                if (abs(offsetFromSnappedPosition) >= abs(pagerState.positionThresholdFraction)) {
                    debugLog { "Crossed Positional Threshold Fraction" }
                    if (isForward) lowerBoundOffset else upperBoundOffset
                } else {
                    // if we haven't scrolled minimally, settle for the closest bound
                    debugLog { "Snap To Closest" }
                    if (lowerBoundOffset.absoluteValue < upperBoundOffset.absoluteValue) {
                        lowerBoundOffset
                    } else {
                        upperBoundOffset
                    }
                }
            }
        }
        FinalSnappingItem.NextItem -> upperBoundOffset
        FinalSnappingItem.PreviousItem -> lowerBoundOffset
        else -> 0f
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy