[go: nahoru, domu]

Merge "Only do relayout when pager scrolling doesn't require composing/disposing" into androidx-main
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt
index 614d0d4..1350683 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt
@@ -58,6 +58,7 @@
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
+import kotlin.math.absoluteValue
 import kotlin.test.assertTrue
 import kotlinx.coroutines.CoroutineScope
 
@@ -357,12 +358,12 @@
             pagerState.currentPageOffsetFraction != 0.0f
         } // wait for first move from drag
         rule.mainClock.advanceTimeUntil {
-            pagerState.currentPageOffsetFraction == 0.0f
+            pagerState.currentPageOffsetFraction.absoluteValue < 0.00001
         } // wait for fling settling
         // pump the clock twice and check we're still settled.
         rule.mainClock.advanceTimeByFrame()
         rule.mainClock.advanceTimeByFrame()
-        assertTrue { pagerState.currentPageOffsetFraction == 0.0f }
+        assertTrue { pagerState.currentPageOffsetFraction.absoluteValue < 0.00001 }
     }
 }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PageLayoutPositionOnScrollingTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PageLayoutPositionOnScrollingTest.kt
index 69ef744..71635fb 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PageLayoutPositionOnScrollingTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PageLayoutPositionOnScrollingTest.kt
@@ -89,10 +89,10 @@
                                 add(
                                     ParamConfig(
                                         orientation = orientation,
-                                        pageSpacing = pageSpacing,
                                         mainAxisContentPadding = contentPadding,
                                         reverseLayout = reverseLayout,
-                                        layoutDirection = layoutDirection
+                                        layoutDirection = layoutDirection,
+                                        pageSpacing = pageSpacing
                                     )
                                 )
                             }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerStateNonGestureScrollingTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerStateNonGestureScrollingTest.kt
index b94c526..37a5df7 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerStateNonGestureScrollingTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerStateNonGestureScrollingTest.kt
@@ -18,6 +18,7 @@
 
 import androidx.compose.foundation.AutoTestFrameClock
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.scrollBy
 import androidx.compose.foundation.gestures.snapping.SnapPosition
 import androidx.compose.foundation.gestures.snapping.calculateDistanceToDesiredSnapPosition
 import androidx.compose.foundation.layout.Box
@@ -41,7 +42,9 @@
 import androidx.compose.ui.util.fastMaxBy
 import androidx.test.filters.LargeTest
 import com.google.common.truth.Truth
+import com.google.common.truth.Truth.assertThat
 import kotlin.math.abs
+import kotlin.math.roundToInt
 import kotlin.test.assertFalse
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.delay
@@ -537,6 +540,105 @@
         }
     }
 
+    @Test
+    fun canScrollForwardAndBackward_afterSmallScrollFromStart() {
+        val pageSizePx = 100
+        val pageSizeDp = with(rule.density) { pageSizePx.toDp() }
+        createPager(
+            modifier = Modifier.size(pageSizeDp * 1.5f),
+            pageSize = { PageSize.Fixed(pageSizeDp) })
+
+        val delta = (pageSizePx / 3f).roundToInt()
+
+        runBlocking {
+            withContext(Dispatchers.Main + AutoTestFrameClock()) {
+                // small enough scroll to not cause any new items to be composed or old ones disposed.
+                pagerState.scrollBy(delta.toFloat())
+            }
+            rule.runOnIdle {
+                assertThat(pagerState.firstVisiblePageOffset).isEqualTo(delta)
+                assertThat(pagerState.canScrollForward).isTrue()
+                assertThat(pagerState.canScrollBackward).isTrue()
+            }
+            // and scroll back to start
+            withContext(Dispatchers.Main + AutoTestFrameClock()) {
+                pagerState.scrollBy(-delta.toFloat())
+            }
+            rule.runOnIdle {
+                assertThat(pagerState.canScrollForward).isTrue()
+                assertThat(pagerState.canScrollBackward).isFalse()
+            }
+        }
+    }
+
+    @Test
+    fun canScrollForwardAndBackward_afterSmallScrollFromEnd() {
+        val pageSizePx = 100
+        val pageSizeDp = with(rule.density) { pageSizePx.toDp() }
+        createPager(
+            modifier = Modifier.size(pageSizeDp * 1.5f),
+            pageSize = { PageSize.Fixed(pageSizeDp) })
+        val delta = -(pageSizePx / 3f).roundToInt()
+        runBlocking {
+            withContext(Dispatchers.Main + AutoTestFrameClock()) {
+                // scroll to the end of the list.
+                pagerState.scrollToPage(DefaultPageCount)
+                // small enough scroll to not cause any new items to be composed or old ones disposed.
+                pagerState.scrollBy(delta.toFloat())
+            }
+            rule.runOnIdle {
+                assertThat(pagerState.canScrollForward).isTrue()
+                assertThat(pagerState.canScrollBackward).isTrue()
+            }
+            // and scroll back to the end
+            withContext(Dispatchers.Main + AutoTestFrameClock()) {
+                pagerState.scrollBy(-delta.toFloat())
+            }
+            rule.runOnIdle {
+                assertThat(pagerState.canScrollForward).isFalse()
+                assertThat(pagerState.canScrollBackward).isTrue()
+            }
+        }
+    }
+
+    @Test
+    fun canScrollForwardAndBackward_afterSmallScrollFromEnd_withContentPadding() {
+        val pageSizePx = 100
+        val pageSizeDp = with(rule.density) { pageSizePx.toDp() }
+        val afterContentPaddingDp = with(rule.density) { 2.toDp() }
+        createPager(
+            modifier = Modifier.size(pageSizeDp * 1.5f),
+            pageSize = { PageSize.Fixed(pageSizeDp) },
+            contentPadding = PaddingValues(afterContent = afterContentPaddingDp)
+        )
+
+        val delta = -(pageSizePx / 3f).roundToInt()
+        runBlocking {
+            withContext(Dispatchers.Main + AutoTestFrameClock()) {
+                // scroll to the end of the list.
+                pagerState.scrollToPage(DefaultPageCount)
+
+                assertThat(pagerState.canScrollForward).isFalse()
+                assertThat(pagerState.canScrollBackward).isTrue()
+
+                // small enough scroll to not cause any new pages to be composed or old ones disposed.
+                pagerState.scrollBy(delta.toFloat())
+            }
+            rule.runOnIdle {
+                assertThat(pagerState.canScrollForward).isTrue()
+                assertThat(pagerState.canScrollBackward).isTrue()
+            }
+            // and scroll back to the end
+            withContext(Dispatchers.Main + AutoTestFrameClock()) {
+                pagerState.scrollBy(-delta.toFloat())
+            }
+            rule.runOnIdle {
+                assertThat(pagerState.canScrollForward).isFalse()
+                assertThat(pagerState.canScrollBackward).isTrue()
+            }
+        }
+    }
+
     companion object {
         @JvmStatic
         @Parameterized.Parameters(name = "{0}")
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt
index 45748d4..6586433 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/MeasuredPage.kt
@@ -78,14 +78,14 @@
             if (isVertical) {
                 placeableOffsets[indexInArray] =
                     requireNotNull(horizontalAlignment) { "null horizontalAlignment" }
-                    .align(placeable.width, layoutWidth, layoutDirection)
+                        .align(placeable.width, layoutWidth, layoutDirection)
                 placeableOffsets[indexInArray + 1] = mainAxisOffset
                 mainAxisOffset += placeable.height
             } else {
                 placeableOffsets[indexInArray] = mainAxisOffset
                 placeableOffsets[indexInArray + 1] =
                     requireNotNull(verticalAlignment) { "null verticalAlignment" }
-                    .align(placeable.height, layoutHeight)
+                        .align(placeable.height, layoutHeight)
                 mainAxisOffset += placeable.width
             }
         }
@@ -110,8 +110,20 @@
         }
     }
 
+    fun applyScrollDelta(delta: Int) {
+        offset += delta
+        repeat(placeableOffsets.size) { index ->
+            // placeableOffsets consist of x and y pairs for each placeable.
+            // if isVertical is true then the main axis offsets are located at indexes 1, 3, 5 etc.
+            if ((isVertical && index % 2 == 1) || (!isVertical && index % 2 == 0)) {
+                placeableOffsets[index] += delta
+            }
+        }
+    }
+
     private fun getOffset(index: Int) =
         IntOffset(placeableOffsets[index * 2], placeableOffsets[index * 2 + 1])
+
     private val Placeable.mainAxisSize get() = if (isVertical) height else width
     private inline fun IntOffset.copy(mainAxisMap: (Int) -> Int): IntOffset =
         IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y)
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
index c60b0b8..bef2c4f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
@@ -22,6 +22,7 @@
 import androidx.compose.foundation.gestures.snapping.calculateDistanceToDesiredSnapPosition
 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
@@ -56,6 +57,7 @@
     beyondBoundsPageCount: Int,
     pinnedPages: List<Int>,
     snapPosition: SnapPosition,
+    placementScopeInvalidator: ObservableScopeInvalidator,
     layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
 ): PagerMeasureResult {
     require(beforeContentPadding >= 0) { "negative beforeContentPadding" }
@@ -86,7 +88,8 @@
             canScrollForward = false,
             currentPage = null,
             currentPageOffsetFraction = 0.0f,
-            snapPosition = snapPosition
+            snapPosition = snapPosition,
+            remeasureNeeded = false
         )
     } else {
 
@@ -185,10 +188,24 @@
         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
-        visiblePages.fastForEach {
-            index++
-            currentMainAxisOffset += pageSizeWithSpacing
+        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" }
@@ -223,9 +240,10 @@
             }
 
             if (currentMainAxisOffset <= minOffset && index != pageCount - 1) {
-                // this page is offscreen and will not be placed. advance firstVisiblePage
+                // 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)
@@ -424,6 +442,8 @@
                 positionedPages.fastForEach {
                     it.place(this)
                 }
+                // we attach it during the placement so PagerState can trigger re-placement
+                placementScopeInvalidator.attachToScope()
             },
             viewportStartOffset = -beforeContentPadding,
             viewportEndOffset = maxOffset + afterContentPadding,
@@ -437,7 +457,8 @@
             canScrollForward = index < pageCount || currentMainAxisOffset > maxOffset,
             currentPage = newCurrentPage,
             currentPageOffsetFraction = currentPageOffsetFraction,
-            snapPosition = snapPosition
+            snapPosition = snapPosition,
+            remeasureNeeded = remeasureNeeded
         )
     }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt
index f255c9e..f5e80fa 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt
@@ -181,8 +181,8 @@
             reverseLayout = reverseLayout,
             pinnedPages = pinnedPages,
             snapPosition = snapPosition,
+            placementScopeInvalidator = state.placementScopeInvalidator,
             layout = { width, height, placement ->
-                state.remeasureTrigger // read state to trigger remeasures on state write
                 layout(
                     containerConstraints.constrainWidth(width + totalHorizontalPadding),
                     containerConstraints.constrainHeight(height + totalVerticalPadding),
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt
index c236604..4d193eb 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt
@@ -21,10 +21,11 @@
 import androidx.compose.foundation.gestures.snapping.SnapPosition
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.util.fastForEach
 
 @OptIn(ExperimentalFoundationApi::class)
 internal class PagerMeasureResult(
-    override val visiblePagesInfo: List<PageInfo>,
+    override val visiblePagesInfo: List<MeasuredPage>,
     override val pageSize: Int,
     override val pageSpacing: Int,
     override val afterContentPadding: Int,
@@ -35,13 +36,86 @@
     override val beyondBoundsPageCount: Int,
     val firstVisiblePage: MeasuredPage?,
     val currentPage: MeasuredPage?,
-    val currentPageOffsetFraction: Float,
-    val firstVisiblePageScrollOffset: Int,
-    val canScrollForward: Boolean,
+    var currentPageOffsetFraction: Float,
+    var firstVisiblePageScrollOffset: Int,
+    var canScrollForward: Boolean,
     override val snapPosition: SnapPosition,
     measureResult: MeasureResult,
+    /** True when extra remeasure is required. */
+    val remeasureNeeded: Boolean,
 ) : PagerLayoutInfo, MeasureResult by measureResult {
     override val viewportSize: IntSize
         get() = IntSize(width, height)
     override val beforeContentPadding: Int get() = -viewportStartOffset
+
+    val canScrollBackward
+        get() = (firstVisiblePage?.index ?: 0) != 0 || firstVisiblePageScrollOffset != 0
+
+    /**
+     * Tries to apply a scroll [delta] for this layout info. In some cases we can apply small
+     * scroll deltas by just changing the offsets for each [visiblePagesInfo].
+     * But we can only do so if after applying the delta we would not need to compose a new item
+     * or dispose an item which is currently visible. In this case this function will not apply
+     * the [delta] and return false.
+     *
+     * @return true if we can safely apply a passed scroll [delta] to this layout info.
+     * If true is returned, only the placement phase is needed to apply new offsets.
+     * If false is returned, it means we have to rerun the full measure phase to apply the [delta].
+     */
+    fun tryToApplyScrollWithoutRemeasure(delta: Int): Boolean {
+        val pageSizeWithSpacing = pageSize + pageSpacing
+        if (remeasureNeeded || visiblePagesInfo.isEmpty() || firstVisiblePage == null ||
+            // applying this delta will change firstVisibleItem
+            (firstVisiblePageScrollOffset - delta) !in 0 until pageSizeWithSpacing
+        ) {
+            return false
+        }
+
+        val deltaFraction = if (pageSizeWithSpacing != 0) {
+            (delta / pageSizeWithSpacing.toFloat())
+        } else {
+            0.0f
+        }
+
+        val newCurrentPageOffsetFraction = currentPageOffsetFraction - deltaFraction
+        if (currentPage == null ||
+            //  applying this delta will change current page
+            newCurrentPageOffsetFraction >= MaxPageOffset ||
+            newCurrentPageOffsetFraction <= MinPageOffset
+        ) {
+            return false
+        }
+
+        val first = visiblePagesInfo.first()
+        val last = visiblePagesInfo.last()
+        val canApply = if (delta < 0) {
+            // scrolling forward
+            val deltaToFirstItemChange =
+                first.offset + pageSizeWithSpacing - viewportStartOffset
+            val deltaToLastItemChange =
+                last.offset + pageSizeWithSpacing - viewportEndOffset
+            minOf(deltaToFirstItemChange, deltaToLastItemChange) > -delta
+        } else {
+            // scrolling backward
+            val deltaToFirstItemChange =
+                viewportStartOffset - first.offset
+            val deltaToLastItemChange =
+                viewportEndOffset - last.offset
+            minOf(deltaToFirstItemChange, deltaToLastItemChange) > delta
+        }
+        return if (canApply) {
+            currentPageOffsetFraction -= deltaFraction
+            firstVisiblePageScrollOffset -= delta
+            visiblePagesInfo.fastForEach {
+                it.applyScrollDelta(delta)
+            }
+            if (!canScrollForward && delta > 0) {
+                // we scrolled backward, so now we can scroll forward
+                canScrollForward = true
+            }
+            true
+        } else {
+            false
+        }
+    }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt
index ea35c64..c7a96e6 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerScrollPosition.kt
@@ -106,6 +106,10 @@
         currentPageOffsetFraction = offsetFraction
     }
 
+    fun updateCurrentPageOffsetFraction(offsetFraction: Float) {
+        currentPageOffsetFraction = offsetFraction
+    }
+
     fun currentAbsoluteScrollOffset(): Int {
         return ((currentPage +
             currentPageOffsetFraction) * state.pageSizeWithSpacing).roundToInt()
@@ -119,7 +123,6 @@
             delta / state.pageSizeWithSpacing.toFloat()
         }
         currentPageOffsetFraction += fractionUpdate
-        state.remeasureTrigger = Unit // trigger remeasure
     }
 }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
index 3f808a9..0459e93 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
@@ -31,6 +31,7 @@
 import androidx.compose.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
 import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
 import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
+import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.derivedStateOf
@@ -46,11 +47,12 @@
 import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.runtime.structuralEqualityPolicy
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.AlignmentLine
+import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.Remeasurement
 import androidx.compose.ui.layout.RemeasurementModifier
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
 import kotlin.math.abs
 import kotlin.math.absoluteValue
@@ -175,8 +177,6 @@
 
     private var isScrollingForward: Boolean by mutableStateOf(false)
 
-    internal var remeasureTrigger by mutableStateOf(Unit, neverEqualPolicy())
-
     internal val scrollPosition = PagerScrollPosition(currentPage, currentPageOffsetFraction, this)
 
     internal var firstVisiblePage = currentPage
@@ -218,14 +218,28 @@
         val newValue = absolute.coerceIn(0.0f, maxScrollOffset)
         val changed = absolute != newValue
         val consumed = newValue - currentScrollPosition
-
+        previousPassDelta = consumed
         if (consumed.absoluteValue != 0.0f) {
             isScrollingForward = consumed > 0.0f
         }
 
         val consumedInt = consumed.roundToInt()
-        scrollPosition.applyScrollDelta(consumedInt)
-        previousPassDelta = consumed
+
+        val layoutInfo = pagerLayoutInfoState.value
+
+        if (layoutInfo.tryToApplyScrollWithoutRemeasure(-consumedInt)) {
+            debugLog { "Will Apply Without Remeasure" }
+            applyMeasureResult(
+                result = layoutInfo,
+                visibleItemsStayedTheSame = true
+            )
+            // we don't need to remeasure, so we only trigger re-placement:
+            placementScopeInvalidator.invalidateScope()
+        } else {
+            debugLog { "Will Apply With Remeasure" }
+            scrollPosition.applyScrollDelta(consumedInt)
+            remeasurement?.forceRemeasure()
+        }
         accumulator = consumed - consumedInt
 
         // Avoid floating-point rounding error
@@ -260,7 +274,8 @@
     private var wasPrefetchingForward = false
 
     /** Backing state for PagerLayoutInfo */
-    private var pagerLayoutInfoState = mutableStateOf<PagerLayoutInfo>(EmptyLayoutInfo)
+    private var pagerLayoutInfoState =
+        mutableStateOf(EmptyLayoutInfo, neverEqualPolicy())
 
     /**
      * A [PagerLayoutInfo] that contains useful information about the Pager's last layout pass.
@@ -425,6 +440,8 @@
 
     internal val nearestRange: IntRange by scrollPosition.nearestRangeState
 
+    internal val placementScopeInvalidator = ObservableScopeInvalidator()
+
     /**
      * Scroll (jump immediately) to a given [page].
      *
@@ -592,20 +609,27 @@
     /**
      *  Updates the state with the new calculated scroll position and consumed scroll.
      */
-    internal fun applyMeasureResult(result: PagerMeasureResult) {
+    internal fun applyMeasureResult(
+        result: PagerMeasureResult,
+        visibleItemsStayedTheSame: Boolean = false
+    ) {
         debugLog { "Applying Measure Result" }
-        scrollPosition.updateFromMeasureResult(result)
+        if (visibleItemsStayedTheSame) {
+            scrollPosition.updateCurrentPageOffsetFraction(result.currentPageOffsetFraction)
+        } else {
+            scrollPosition.updateFromMeasureResult(result)
+            cancelPrefetchIfVisibleItemsChanged(result)
+        }
         pagerLayoutInfoState.value = result
         canScrollForward = result.canScrollForward
-        canScrollBackward = (result.firstVisiblePage?.index ?: 0) != 0 ||
-            result.firstVisiblePageScrollOffset != 0
+        canScrollBackward = result.canScrollBackward
         numMeasurePasses++
         result.firstVisiblePage?.let { firstVisiblePage = it.index }
         firstVisiblePageOffset = result.firstVisiblePageScrollOffset
-        cancelPrefetchIfVisibleItemsChanged(result)
         tryRunPrefetch(result)
         maxScrollOffset = result.calculateNewMaxScrollOffset(pageCount)
-        debugLog { "Finished Applying Measure Result" }
+        debugLog { "Finished Applying Measure Result" +
+            "\nNew maxScrollOffset=$maxScrollOffset" }
     }
 
     private fun tryRunPrefetch(result: PagerMeasureResult) = Snapshot.withoutReadObservation {
@@ -725,20 +749,33 @@
 internal const val PagesToPrefetch = 1
 
 @OptIn(ExperimentalFoundationApi::class)
-internal object EmptyLayoutInfo : PagerLayoutInfo {
-    override val visiblePagesInfo: List<PageInfo> = emptyList()
-    override val pageSize: Int = 0
-    override val pageSpacing: Int = 0
-    override val beforeContentPadding: Int = 0
-    override val afterContentPadding: Int = 0
-    override val viewportSize: IntSize = IntSize.Zero
-    override val orientation: Orientation = Orientation.Horizontal
-    override val viewportStartOffset: Int = 0
-    override val viewportEndOffset: Int = 0
-    override val reverseLayout: Boolean = false
-    override val beyondBoundsPageCount: Int = 0
-    override val snapPosition: SnapPosition = SnapPosition.Start
-}
+internal val EmptyLayoutInfo = PagerMeasureResult(
+    visiblePagesInfo = emptyList(),
+    pageSize = 0,
+    pageSpacing = 0,
+    afterContentPadding = 0,
+    orientation = Orientation.Horizontal,
+    viewportStartOffset = 0,
+    viewportEndOffset = 0,
+    reverseLayout = false,
+    beyondBoundsPageCount = 0,
+    firstVisiblePage = null,
+    firstVisiblePageScrollOffset = 0,
+    currentPage = null,
+    currentPageOffsetFraction = 0.0f,
+    canScrollForward = false,
+    snapPosition = SnapPosition.Start,
+    measureResult = object : MeasureResult {
+        override val width: Int = 0
+
+        override val height: Int = 0
+        @Suppress("PrimitiveInCollection")
+        override val alignmentLines: Map<AlignmentLine, Int> = mapOf()
+
+        override fun placeChildren() {}
+    },
+    remeasureNeeded = false
+)
 
 private val UnitDensity = object : Density {
     override val density: Float = 1f