Merge "[Carousel] Updated carouselItem modifier to clip and translate inside placeWithLayer's layerBlock." into androidx-main
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index ed6822d..40b8512 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -2161,9 +2161,11 @@
   }
 
   @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public sealed interface CarouselItemInfo {
+    method public androidx.compose.ui.geometry.Rect getMaskRect();
     method public float getMaxSize();
     method public float getMinSize();
     method public float getSize();
+    property public abstract androidx.compose.ui.geometry.Rect maskRect;
     property public abstract float maxSize;
     property public abstract float minSize;
     property public abstract float size;
@@ -2197,9 +2199,7 @@
   }
 
   public final class CarouselStateKt {
-    method public static float endOffset(androidx.compose.material3.carousel.CarouselItemInfo);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static androidx.compose.material3.carousel.CarouselState rememberCarouselState(optional int initialItem, kotlin.jvm.functions.Function0<java.lang.Integer> itemCount);
-    method public static float startOffset(androidx.compose.material3.carousel.CarouselItemInfo);
   }
 
 }
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index ed6822d..40b8512 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -2161,9 +2161,11 @@
   }
 
   @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public sealed interface CarouselItemInfo {
+    method public androidx.compose.ui.geometry.Rect getMaskRect();
     method public float getMaxSize();
     method public float getMinSize();
     method public float getSize();
+    property public abstract androidx.compose.ui.geometry.Rect maskRect;
     property public abstract float maxSize;
     property public abstract float minSize;
     property public abstract float size;
@@ -2197,9 +2199,7 @@
   }
 
   public final class CarouselStateKt {
-    method public static float endOffset(androidx.compose.material3.carousel.CarouselItemInfo);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static androidx.compose.material3.carousel.CarouselState rememberCarouselState(optional int initialItem, kotlin.jvm.functions.Function0<java.lang.Integer> itemCount);
-    method public static float startOffset(androidx.compose.material3.carousel.CarouselItemInfo);
   }
 
 }
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt
index 90522ff..cf25fa1 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt
@@ -31,7 +31,6 @@
 import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel
 import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
 import androidx.compose.material3.carousel.rememberCarouselState
-import androidx.compose.material3.carousel.startOffset
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.graphicsLayer
@@ -72,8 +71,7 @@
     ) { i ->
         val item = items[i]
         Card(
-            modifier = Modifier
-                .height(205.dp)
+            modifier = Modifier.height(205.dp)
         ) {
             Image(
                 painter = painterResource(id = item.imageResId),
@@ -177,7 +175,7 @@
                 Text(
                     text = "sample text",
                     modifier = Modifier.graphicsLayer {
-                        translationX = carouselItemInfo.startOffset()
+                        translationX = carouselItemInfo.maskRect.left
                     }
                 )
             }
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt
index 95b8a25..5d529fa 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt
@@ -180,50 +180,19 @@
     }
 
     @Test
-    fun carousel_calculateBeyondViewportPageCount() {
-        val xSmallSize = 5f
-        val smallSize = 100f
-        val mediumSize = 200f
-        val largeSize = 400f
-        val keylineList = keylineListOf(carouselMainAxisSize = 1000f, 0f, CarouselAlignment.Start) {
-            add(xSmallSize, isAnchor = true)
-            add(largeSize)
-            add(mediumSize)
-            add(mediumSize)
-            add(smallSize)
-            add(smallSize)
-            add(xSmallSize, isAnchor = true)
-        }
-        val strategy = Strategy { _, _ -> keylineList }.apply(
-            availableSpace = 1000f,
-            itemSpacing = 0f,
-            beforeContentPadding = 0f,
-            afterContentPadding = 0f
-        )
-        val outOfBoundsNum = calculateBeyondViewportPageCount(strategy)
-        // With this strategy, we expect 3 loaded items
-        val loadedItems = 3
-
-        assertThat(outOfBoundsNum).isEqualTo(
-            (keylineList.filter { !it.isAnchor }.size - loadedItems) + 1
-        )
-    }
-
-    @Test
     fun carousel_correctlyCalculatesMaxScrollOffsetWithItemSpacing() {
         rule.setMaterialContent(lightColorScheme()) {
             val state = rememberCarouselState { 10 }.also {
                 carouselState = it
             }
-            val strategy = Strategy { availableSpace, itemSpacing ->
-                keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Start) {
-                    add(10f, isAnchor = true)
-                    add(186f)
-                    add(122f)
-                    add(56f)
-                    add(10f, isAnchor = true)
-                }
-            }.apply(
+            val strategy = Strategy(
+                defaultKeylines = keylineListOf(380f, 0f, CarouselAlignment.Start) {
+                        add(10f, isAnchor = true)
+                        add(186f)
+                        add(122f)
+                        add(56f)
+                        add(10f, isAnchor = true)
+                },
                 availableSpace = 380f,
                 itemSpacing = 8f,
                 beforeContentPadding = 0f,
@@ -285,6 +254,7 @@
                     )
                 },
                 flingBehavior = flingBehavior(state),
+                maxNonFocalVisibleItemCount = 2,
                 modifier = modifier.testTag(CarouselTestTag),
                 itemSpacing = 0.dp,
                 contentPadding = PaddingValues(0.dp),
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt
index a6b9613..0410575 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt
@@ -27,25 +27,34 @@
     @Test
     fun testSnapPosition_forCenterAlignedStrategy() {
         val itemCount = 6
-        val map = calculateSnapPositions(testCenterAlignedStrategy(), itemCount)
+        val strategy = testCenterAlignedStrategy()
         val expectedSnapPositions = arrayListOf(0, 200, 300, 300, 400, 600)
-        repeat(itemCount) { i -> assertThat(map[i]).isEqualTo(expectedSnapPositions[i]) }
+        repeat(itemCount) { i ->
+            assertThat(getSnapPositionOffset(strategy, i, itemCount))
+                .isEqualTo(expectedSnapPositions[i])
+        }
     }
 
     @Test
     fun testSnapPosition_forStartAlignedStrategy() {
         val itemCount = 6
-        val map = calculateSnapPositions(testStartAlignedStrategy(), itemCount)
+        val strategy = testStartAlignedStrategy()
         val expectedSnapPositions = arrayListOf(0, 0, 100, 200, 400, 600)
-        repeat(itemCount) { i -> assertThat(map[i]).isEqualTo(expectedSnapPositions[i]) }
+        repeat(itemCount) { i ->
+            assertThat(getSnapPositionOffset(strategy, i, itemCount))
+                .isEqualTo(expectedSnapPositions[i])
+        }
     }
 
     @Test
     fun testSnapPosition_forStartAlignedStrategyWithMultipleFocal() {
         val itemCount = 5
-        val map = calculateSnapPositions(testStartAlignedStrategyWithMultipleFocal(), itemCount)
+        val strategy = testStartAlignedStrategyWithMultipleFocal()
         val expectedSnapPositions = arrayListOf(0, 0, 75, 200, 200)
-        repeat(itemCount) { i -> assertThat(map[i]).isEqualTo(expectedSnapPositions[i]) }
+        repeat(itemCount) { i ->
+            assertThat(getSnapPositionOffset(strategy, i, itemCount))
+                .isEqualTo(expectedSnapPositions[i])
+        }
     }
 
     @Test
@@ -53,9 +62,11 @@
         val strategy = testStartAlignedStrategyWithMultipleFocal()
         // item count is the number of keylines minus anchor keylines
         val itemCount = strategy.defaultKeylines.size - 2
-        val map = calculateSnapPositions(strategy, itemCount)
         val expectedSnapPositions = arrayListOf(0, 75, 200, 200)
-        repeat(itemCount) { i -> assertThat(map[i]).isEqualTo(expectedSnapPositions[i]) }
+        repeat(itemCount) { i ->
+            assertThat(getSnapPositionOffset(strategy, i, itemCount))
+                .isEqualTo(expectedSnapPositions[i])
+        }
     }
 
     @Test
@@ -64,8 +75,9 @@
         // item count is the number of focal keylines minus one
         val itemCount = strategy.defaultKeylines.lastFocalIndex -
             strategy.defaultKeylines.firstFocalIndex
-        val map = calculateSnapPositions(strategy, itemCount)
-        repeat(itemCount) { i -> assertThat(map[i]).isEqualTo(0) }
+        repeat(itemCount) { i ->
+            assertThat(getSnapPositionOffset(strategy, i, itemCount)).isEqualTo(0)
+        }
     }
 
     // Test strategy that is center aligned and has a complex keyline state, ie:
@@ -97,7 +109,8 @@
             add(xSmallSize, isAnchor = true)
         }
 
-        return Strategy { _, _ -> keylineList }.apply(
+        return Strategy(
+            defaultKeylines = keylineList,
             availableSpace = 1000f,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -132,7 +145,8 @@
             add(smallSize)
             add(xSmallSize, isAnchor = true)
         }
-        return Strategy { _, _ -> keylineList }.apply(
+        return Strategy(
+            defaultKeylines = keylineList,
             availableSpace = 1000f,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -160,7 +174,8 @@
             add(smallSize)
             add(xSmallSize, isAnchor = true)
         }
-        return Strategy { _, _ -> keylineList }.apply(
+        return Strategy(
+            defaultKeylines = keylineList,
             availableSpace = 1000f,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt
index 830559f..6f829c0 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt
@@ -38,8 +38,9 @@
             preferredItemSize = itemSize,
             itemSpacing = 0f,
             itemCount = 10,
-        )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+        )
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = 500f,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -58,8 +59,9 @@
             preferredItemSize = itemSize,
             itemSpacing = 0f,
             itemCount = 10,
-            )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+            )
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = 100f,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -87,8 +89,9 @@
             preferredItemSize = 200f,
             itemSpacing = 0f,
             itemCount = 10,
-            )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+            )
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = minSmallItemSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -110,7 +113,7 @@
             itemSpacing = 0f,
             itemCount = 10,
             )
-        assertThat(keylineList).isNull()
+        assertThat(keylineList).isEmpty()
     }
 
     @Test
@@ -124,8 +127,9 @@
             preferredItemSize = preferredItemSize,
             itemSpacing = 0f,
             itemCount = 10,
-            )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+            )
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = carouselSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -154,8 +158,9 @@
             preferredItemSize = preferredItemSize,
             itemSpacing = 0f,
             itemCount = 3,
-        )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+        )
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = carouselSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -179,8 +184,9 @@
             preferredItemSize = 186f,
             itemSpacing = 8f,
             itemCount = 10
-        )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+        )
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = 380f,
             itemSpacing = 8f,
             beforeContentPadding = 0f,
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt
index e19e370..1606293 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt
@@ -34,7 +34,8 @@
         val maxScrollOffset = (itemCount * large) - carouselMainAxisSize
         val defaultKeylineList = createStartAlignedKeylineList()
 
-        val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = defaultKeylineList,
             availableSpace = carouselMainAxisSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -67,13 +68,14 @@
         val cutoff = 50f
         val defaultKeylineList = createStartAlignedCutoffKeylineList(cutoff = cutoff)
 
-        val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = defaultKeylineList,
             availableSpace = carouselMainAxisSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
         )
-        val endKeylineList = strategy.getEndKeylines()
+        val endKeylineList = strategy.endKeylineSteps.last()
 
         assertThat(defaultKeylineList.lastNonAnchor.cutoff).isEqualTo(cutoff)
         assertThat(defaultKeylineList.firstNonAnchor.offset -
@@ -91,13 +93,14 @@
         val cutoff = 50f
         val defaultKeylineList = createEndAlignedCutoffKeylineList(cutoff = cutoff)
 
-        val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = defaultKeylineList,
             availableSpace = carouselMainAxisSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
         )
-        val startKeylineList = strategy.getStartKeylines()
+        val startKeylineList = strategy.startKeylineSteps.last()
 
         assertThat(defaultKeylineList.firstNonAnchor.cutoff).isEqualTo(cutoff)
         assertThat(defaultKeylineList.lastNonAnchor.offset +
@@ -116,7 +119,8 @@
         val maxScrollOffset = (itemCount * large) - carouselMainAxisSize
         val defaultKeylines = createCenterAlignedKeylineList()
 
-        val strategy = Strategy { _, _ -> defaultKeylines }.apply(
+        val strategy = Strategy(
+            defaultKeylines = defaultKeylines,
             availableSpace = carouselMainAxisSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -225,7 +229,8 @@
         val maxScrollOffset = (itemCount * large) - carouselMainAxisSize
         val defaultKeylines = createCenterAlignedKeylineList()
 
-        val strategy = Strategy { _, _ -> defaultKeylines }.apply(
+        val strategy = Strategy(
+            defaultKeylines = defaultKeylines,
             availableSpace = carouselMainAxisSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -357,21 +362,20 @@
     fun testStrategy_sameAvailableSpaceCreatesEqualObjects() {
         val itemSize = large
         val itemCount = 10
+        val availableSpace = 500f
         val itemSpacing = 0f
-        val strategy1 = Strategy { availableSpace, itemSpacingPx ->
-            multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
-        }
-        val strategy2 = Strategy { availableSpace, itemSpacingPx ->
-            multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
-        }
-        strategy1.apply(
-            availableSpace = 500f,
+        val strategy1 = Strategy(
+            defaultKeylines =
+                multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacing, itemCount),
+            availableSpace = availableSpace,
             itemSpacing = itemSpacing,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
         )
-        strategy2.apply(
-            availableSpace = 500f,
+        val strategy2 = Strategy(
+            defaultKeylines =
+                multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacing, itemCount),
+            availableSpace = availableSpace,
             itemSpacing = itemSpacing,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
@@ -386,20 +390,18 @@
         val itemSize = large
         val itemSpacing = 0f
         val itemCount = 10
-        val strategy1 = Strategy { availableSpace, itemSpacingPx ->
-            multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
-        }
-        val strategy2 = Strategy { availableSpace, itemSpacingPx ->
-            multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
-        }
-        strategy1.apply(
+        val strategy1 = Strategy(
+            defaultKeylines =
+                multiBrowseKeylineList(Density, 500f, itemSize, itemSpacing, itemCount),
             availableSpace = 500f,
             itemSpacing = itemSpacing,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
         )
-        strategy2.apply(
-            availableSpace = 500f + 1f,
+        val strategy2 = Strategy(
+            defaultKeylines =
+                multiBrowseKeylineList(Density, 501f, itemSize, itemSpacing, itemCount),
+            availableSpace = 501f,
             itemSpacing = itemSpacing,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
@@ -412,16 +414,20 @@
     @Test
     fun testStrategy_invalidObjectDoesNotEqualValidObject() {
         val itemSize = large
+        val availableSpace = 500f
         val itemSpacing = 0f
         val itemCount = 10
-        val strategy1 = Strategy { availableSpace, itemSpacingPx ->
-            multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
-        }
-        val strategy2 = Strategy { availableSpace, itemSpacingPx ->
-            multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
-        }
-        strategy1.apply(
-            availableSpace = 500f,
+        val strategy1 = Strategy(
+            defaultKeylines =
+                multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacing, itemCount),
+            availableSpace = availableSpace,
+            itemSpacing = itemSpacing,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
+        val strategy2 = Strategy(
+            defaultKeylines = emptyKeylineList(),
+            availableSpace = availableSpace,
             itemSpacing = itemSpacing,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
@@ -438,7 +444,8 @@
         val maxScrollOffset = (itemCount * large) - carouselMainAxisSize
         val defaultKeylineList = createStartAlignedKeylineList()
 
-        val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = defaultKeylineList,
             availableSpace = carouselMainAxisSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -451,17 +458,19 @@
 
     @Test
     fun testStartKeylineStrategy_endStepsShouldAccountForItemSpacing() {
-        val strategy = Strategy { availableSpace, itemSpacing ->
-            keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Start) {
-                add(10f, isAnchor = true)
-                add(186f)
-                add(122f)
-                add(56f)
-                add(10f, isAnchor = true)
-            }
-        }.apply(
-            availableSpace = 380f,
-            itemSpacing = 8f,
+        val availableSpace = 380f
+        val itemSpacing = 8f
+        val strategy = Strategy(
+            defaultKeylines =
+                keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Start) {
+                    add(10f, isAnchor = true)
+                    add(186f)
+                    add(122f)
+                    add(56f)
+                    add(10f, isAnchor = true)
+                },
+            availableSpace = availableSpace,
+            itemSpacing = itemSpacing,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
         )
@@ -487,8 +496,10 @@
 
     @Test
     fun testCenterKeylineStrategy_startAndEndStepsShouldAccountForItemSpacing() {
-        val strategy = Strategy { availableSpace, itemSpacing ->
-            keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Center) {
+        val availableSpace = 768f
+        val itemSpacing = 8f
+        val strategy = Strategy(
+            defaultKeylines = keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Center) {
                 add(10f, isAnchor = true)
                 add(56f)
                 add(122f)
@@ -497,10 +508,9 @@
                 add(122f)
                 add(56f)
                 add(10f, isAnchor = true)
-            }
-        }.apply(
-            availableSpace = 768f,
-            itemSpacing = 8f,
+            },
+            availableSpace = availableSpace,
+            itemSpacing = itemSpacing,
             beforeContentPadding = 0f,
             afterContentPadding = 0f
         )
@@ -559,17 +569,24 @@
 
     @Test
     fun testCenterStrategy_stepsShouldAccountForContentPadding() {
-        val strategy = Strategy { availableSpace, itemSpacing ->
-            keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Center) {
-                add(10f, isAnchor = true)
-                add(50f)
-                add(100f)
-                add(200f)
-                add(100f)
-                add(50f)
-                add(10f, isAnchor = true)
-            }
-        }.apply(500f, 0f, 16f, 24f)
+        val availableSpace = 500f
+        val itemSpacing = 0f
+        val strategy = Strategy(
+            defaultKeylines =
+                keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Center) {
+                    add(10f, isAnchor = true)
+                    add(50f)
+                    add(100f)
+                    add(200f)
+                    add(100f)
+                    add(50f)
+                    add(10f, isAnchor = true)
+                },
+            availableSpace = availableSpace,
+            itemSpacing = itemSpacing,
+            beforeContentPadding = 16f,
+            afterContentPadding = 24f
+        )
 
         val lastStartStep = strategy.startKeylineSteps.last()
 
@@ -591,17 +608,19 @@
 
     @Test
     fun testStartStrategy_twoLargeOneSmall_shouldAccountForPadding() {
-        val strategy = Strategy { availableSpace, itemSpacing ->
-            keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Start) {
-                add(10f, isAnchor = true)
-                add(186f)
-                add(186f)
-                add(56f)
-                add(10f, isAnchor = true)
-            }
-        }.apply(
-            availableSpace = 444f,
-            itemSpacing = 8f,
+        val availableSpace = 444f
+        val itemSpacing = 8f
+        val strategy = Strategy(
+            defaultKeylines =
+                keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Start) {
+                    add(10f, isAnchor = true)
+                    add(186f)
+                    add(186f)
+                    add(56f)
+                    add(10f, isAnchor = true)
+                },
+            availableSpace = availableSpace,
+            itemSpacing = itemSpacing,
             beforeContentPadding = 16f,
             afterContentPadding = 16f
         )
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt
index cf6aa49..1a74bad 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt
@@ -39,7 +39,8 @@
             itemSize = itemSize,
             itemSpacing = 0f
         )
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = carouselSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -67,7 +68,8 @@
             itemSize = itemSize,
             itemSpacing = 0f
         )
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = carouselSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -98,7 +100,8 @@
             itemSize = itemSize,
             itemSpacing = 0f
         )
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = carouselSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
@@ -138,7 +141,8 @@
             itemSize = itemSize,
             itemSpacing = 0f
         )
-        val strategy = Strategy { _, _ -> keylineList }.apply(
+        val strategy = Strategy(
+            defaultKeylines = keylineList,
             availableSpace = carouselSize,
             itemSpacing = 0f,
             beforeContentPadding = 0f,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
index 65effa4..8984250 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
@@ -17,7 +17,6 @@
 package androidx.compose.material3.carousel
 
 import androidx.annotation.VisibleForTesting
-import androidx.collection.IntIntMap
 import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.animation.core.DecayAnimationSpec
 import androidx.compose.animation.core.Spring
@@ -41,7 +40,10 @@
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ShapeDefaults
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.CornerRadius
 import androidx.compose.ui.geometry.Rect
@@ -49,7 +51,6 @@
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Outline
 import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.layout.layout
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalLayoutDirection
@@ -57,9 +58,6 @@
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
-import androidx.compose.ui.util.fastFilter
-import androidx.compose.ui.util.fastForEach
-import kotlin.math.ceil
 import kotlin.math.roundToInt
 
 /**
@@ -132,6 +130,9 @@
             }
         },
         contentPadding = contentPadding,
+        // 2 is the max number of medium and small items that can be present in a multi-browse
+        // carousel and should be the upper bounds max non focal visible items.
+        maxNonFocalVisibleItemCount = 2,
         modifier = modifier,
         itemSpacing = itemSpacing,
         flingBehavior = flingBehavior,
@@ -191,6 +192,9 @@
             }
         },
         contentPadding = contentPadding,
+        // Since uncontained carousels only have one item that masks as it moves in/out of view,
+        // there is no need to increase the max non focal count.
+        maxNonFocalVisibleItemCount = 0,
         modifier = modifier,
         itemSpacing = itemSpacing,
         flingBehavior = flingBehavior,
@@ -209,6 +213,9 @@
  * @param keylineList The list of keylines that are fixed positions along the scrolling axis which
  * define the state an item should be in when its center is co-located with the keyline's position.
  * @param contentPadding a padding around the whole content. This will add padding for the
+ * @param maxNonFocalVisibleItemCount the maximum number of items that are visible but not fully
+ * unmasked (focal) at one time. This number helps determine how many items should be composed
+ * to fill the entire viewport.
  * @param modifier A modifier instance to be applied to this carousel outer layout
  * content after it has been clipped. You can use it to add a padding before the first item or
  * after the last one. Use [itemSpacing] to add spacing between the items.
@@ -222,32 +229,22 @@
 internal fun Carousel(
     state: CarouselState,
     orientation: Orientation,
-    keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList?,
+    keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList,
     contentPadding: PaddingValues,
+    maxNonFocalVisibleItemCount: Int,
     modifier: Modifier = Modifier,
     itemSpacing: Dp = 0.dp,
     flingBehavior: TargetedFlingBehavior =
         CarouselDefaults.singleAdvanceFlingBehavior(state = state),
     content: @Composable CarouselItemScope.(itemIndex: Int) -> Unit
 ) {
-    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
     val beforeContentPadding = contentPadding.calculateBeforeContentPadding(orientation)
     val afterContentPadding = contentPadding.calculateAfterContentPadding(orientation)
     val pageSize = remember(keylineList) {
         CarouselPageSize(keylineList, beforeContentPadding, afterContentPadding)
     }
 
-    val beyondViewportPageCount = remember(pageSize.strategy.itemMainAxisSize) {
-        calculateBeyondViewportPageCount(pageSize.strategy)
-    }
-
-    val snapPositionMap = remember(pageSize.strategy.itemMainAxisSize) {
-        calculateSnapPositions(
-            pageSize.strategy,
-            state.itemCountState.value()
-        )
-    }
-    val snapPosition = remember(snapPositionMap) { KeylineSnapPosition(snapPositionMap) }
+    val snapPosition = KeylineSnapPosition(pageSize)
 
     if (orientation == Orientation.Horizontal) {
         HorizontalPager(
@@ -259,7 +256,7 @@
             ),
             pageSize = pageSize,
             pageSpacing = itemSpacing,
-            beyondViewportPageCount = beyondViewportPageCount,
+            beyondViewportPageCount = maxNonFocalVisibleItemCount,
             snapPosition = snapPosition,
             flingBehavior = flingBehavior,
             modifier = modifier
@@ -271,10 +268,8 @@
                 modifier = Modifier.carouselItem(
                     index = page,
                     state = state,
-                    strategy = pageSize.strategy,
-                    itemPositionMap = snapPositionMap,
+                    strategy = { pageSize.strategy },
                     carouselItemInfo = carouselItemInfo,
-                    isRtl = isRtl
                 )
             ) {
                 scope.content(page)
@@ -290,7 +285,7 @@
             ),
             pageSize = pageSize,
             pageSpacing = itemSpacing,
-            beyondViewportPageCount = beyondViewportPageCount,
+            beyondViewportPageCount = maxNonFocalVisibleItemCount,
             snapPosition = snapPosition,
             flingBehavior = flingBehavior,
             modifier = modifier
@@ -302,10 +297,8 @@
                 modifier = Modifier.carouselItem(
                     index = page,
                     state = state,
-                    strategy = pageSize.strategy,
-                    itemPositionMap = snapPositionMap,
+                    strategy = { pageSize.strategy },
                     carouselItemInfo = carouselItemInfo,
-                    isRtl = isRtl
                 )
             ) {
                 scope.content(page)
@@ -336,23 +329,6 @@
     return with(LocalDensity.current) { dpValue.toPx() }
 }
 
-internal fun calculateBeyondViewportPageCount(strategy: Strategy): Int {
-    if (!strategy.isValid()) {
-        return PagerDefaults.BeyondViewportPageCount
-    }
-    var totalKeylineSpace = 0f
-    var totalNonAnchorKeylines = 0
-    strategy.defaultKeylines.fastFilter { !it.isAnchor }.fastForEach {
-        totalKeylineSpace += it.size
-        totalNonAnchorKeylines += 1
-    }
-    val itemsLoaded = ceil(totalKeylineSpace / strategy.itemMainAxisSize).toInt()
-    val itemsToLoad = totalNonAnchorKeylines - itemsLoaded
-
-    // We must also load the next item when scrolling
-    return itemsToLoad + 1
-}
-
 /**
  * A [PageSize] implementation that maintains a strategy that is kept up-to-date with the
  * latest available space of the container.
@@ -360,24 +336,31 @@
  * @param keylineList The list of keylines that are fixed positions along the scrolling axis which
  * define the state an item should be in when its center is co-located with the keyline's position.
  */
-private class CarouselPageSize(
-    keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList?,
+internal class CarouselPageSize(
+    private val keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList,
     private val beforeContentPadding: Float,
     private val afterContentPadding: Float
 ) : PageSize {
-    val strategy = Strategy(keylineList)
+
+    private var strategyState by mutableStateOf(Strategy.Empty)
+    val strategy: Strategy
+        get() = strategyState
+
     override fun Density.calculateMainAxisPageSize(availableSpace: Int, pageSpacing: Int): Int {
-        strategy.apply(
+        val keylines = keylineList.invoke(availableSpace.toFloat(), pageSpacing.toFloat())
+        strategyState = Strategy(
+            keylines,
             availableSpace.toFloat(),
             pageSpacing.toFloat(),
             beforeContentPadding,
             afterContentPadding
         )
-        return if (strategy.isValid()) {
+
+        // If a valid strategy is available, use the strategy's item size. Otherwise, default to
+        // a full size item as Pager does by default.
+        return if (strategy.isValid) {
             strategy.itemMainAxisSize.roundToInt()
         } else {
-            // If strategy does not have a valid arrangement, default to a
-            // full size item, as Pager does by default.
             availableSpace
         }
     }
@@ -407,25 +390,27 @@
  * @param index the index of the item in the carousel
  * @param state the carousel state
  * @param strategy the strategy used to mask and translate items in the carousel
- * @param itemPositionMap the position of each index when it is the current item
  * @param carouselItemInfo the item info that should be updated with the changes in this modifier
- * @param isRtl true if the layout direction is right-to-left
  */
 @OptIn(ExperimentalMaterial3Api::class)
 internal fun Modifier.carouselItem(
     index: Int,
     state: CarouselState,
-    strategy: Strategy,
-    itemPositionMap: IntIntMap,
+    strategy: () -> Strategy,
     carouselItemInfo: CarouselItemInfoImpl,
-    isRtl: Boolean
 ): Modifier {
-    if (!strategy.isValid()) return this
-    val isVertical = state.pagerState.layoutInfo.orientation == Orientation.Vertical
-
     return layout { measurable, constraints ->
+        val strategyResult = strategy.invoke()
+        if (!strategyResult.isValid) {
+            // If there is no strategy, avoid displaying content
+            return@layout layout(0, 0) { }
+        }
+
+        val isVertical = state.pagerState.layoutInfo.orientation == Orientation.Vertical
+        val isRtl = layoutDirection == LayoutDirection.Rtl
+
         // Force the item to use the strategy's itemMainAxisSize along its main axis
-        val mainAxisSize = strategy.itemMainAxisSize
+        val mainAxisSize = strategyResult.itemMainAxisSize
         val itemConstraints = if (isVertical) {
             constraints.copy(
                 minWidth = constraints.minWidth,
@@ -444,97 +429,103 @@
 
         val placeable = measurable.measure(itemConstraints)
         layout(placeable.width, placeable.height) {
-            placeable.place(0, 0)
-        }
-    }.graphicsLayer {
-        val scrollOffset = calculateCurrentScrollOffset(state, strategy, itemPositionMap)
-        val maxScrollOffset = calculateMaxScrollOffset(state, strategy)
-        // TODO: Reduce the number of times a keyline for the same scroll offset is calculated
-        val keylines = strategy.getKeylineListForScrollOffset(scrollOffset, maxScrollOffset)
-        val roundedKeylines = strategy.getKeylineListForScrollOffset(
-            scrollOffset = scrollOffset,
-            maxScrollOffset = maxScrollOffset,
-            roundToNearestStep = true
-        )
-
-        // Find center of the item at this index
-        val itemSizeWithSpacing = strategy.itemMainAxisSize + strategy.itemSpacing
-        val unadjustedCenter =
-            (index * itemSizeWithSpacing) + (strategy.itemMainAxisSize / 2f) - scrollOffset
-
-        // Find the keyline before and after this item's center and create an interpolated
-        // keyline that the item should use for its clip shape and offset
-        val keylineBefore =
-            keylines.getKeylineBefore(unadjustedCenter)
-        val keylineAfter =
-            keylines.getKeylineAfter(unadjustedCenter)
-        val progress = getProgress(keylineBefore, keylineAfter, unadjustedCenter)
-        val interpolatedKeyline = lerp(keylineBefore, keylineAfter, progress)
-        val isOutOfKeylineBounds = keylineBefore == keylineAfter
-
-        val centerX =
-            if (isVertical) size.height / 2f else strategy.itemMainAxisSize / 2f
-        val centerY =
-            if (isVertical) strategy.itemMainAxisSize / 2f else size.height / 2f
-        val halfMaskWidth =
-            if (isVertical) size.width / 2f else interpolatedKeyline.size / 2f
-        val halfMaskHeight =
-            if (isVertical) interpolatedKeyline.size / 2f else size.height / 2f
-        val maskRect = Rect(
-            left = centerX - halfMaskWidth,
-            top = centerY - halfMaskHeight,
-            right = centerX + halfMaskWidth,
-            bottom = centerY + halfMaskHeight
-        )
-
-        // Update carousel item info
-        carouselItemInfo.sizeState.floatValue = interpolatedKeyline.size
-        carouselItemInfo.minSizeState.floatValue = roundedKeylines.minBy { it.size }.size
-        carouselItemInfo.maxSizeState.floatValue = roundedKeylines.firstFocal.size
-
-        // Clip the item
-        clip = true
-        shape = object : Shape {
-            // TODO: Find a way to use the shape of the item set by the client for each item
-            // TODO: Allow corner size customization
-            val roundedCornerShape = RoundedCornerShape(ShapeDefaults.ExtraLarge.topStart)
-            override fun createOutline(
-                size: Size,
-                layoutDirection: LayoutDirection,
-                density: Density
-            ): Outline {
-                val cornerSize =
-                    roundedCornerShape.topStart.toPx(
-                        Size(maskRect.width, maskRect.height),
-                        density
-                    )
-                val cornerRadius = CornerRadius(cornerSize)
-                return Outline.Rounded(
-                    RoundRect(
-                        rect = maskRect,
-                        topLeft = cornerRadius,
-                        topRight = cornerRadius,
-                        bottomRight = cornerRadius,
-                        bottomLeft = cornerRadius
-                    )
+            placeable.placeWithLayer(0, 0, layerBlock = {
+                val scrollOffset = calculateCurrentScrollOffset(state, strategyResult)
+                val maxScrollOffset = calculateMaxScrollOffset(state, strategyResult)
+                // TODO: Reduce the number of times keylins are calculated
+                val keylines = strategyResult.getKeylineListForScrollOffset(
+                    scrollOffset,
+                    maxScrollOffset
                 )
-            }
-        }
+                val roundedKeylines = strategyResult.getKeylineListForScrollOffset(
+                    scrollOffset = scrollOffset,
+                    maxScrollOffset = maxScrollOffset,
+                    roundToNearestStep = true
+                )
 
-        // After clipping, the items will have white space between them. Translate the items to
-        // pin their edges together
-        var translation = interpolatedKeyline.offset - unadjustedCenter
-        if (isOutOfKeylineBounds) {
-            // If this item is beyond the first or last keyline, continue to offset the item
-            // by cutting its unadjustedOffset according to its masked size.
-            val outOfBoundsOffset = (unadjustedCenter - interpolatedKeyline.unadjustedOffset) /
-                interpolatedKeyline.size
-            translation += outOfBoundsOffset
-        }
-        if (isVertical) {
-            translationY = translation
-        } else {
-            translationX = if (isRtl) -translation else translation
+                // Find center of the item at this index
+                val itemSizeWithSpacing = strategyResult.itemMainAxisSize +
+                    strategyResult.itemSpacing
+                val unadjustedCenter = (index * itemSizeWithSpacing) +
+                    (strategyResult.itemMainAxisSize / 2f) - scrollOffset
+
+                // Find the keyline before and after this item's center and create an interpolated
+                // keyline that the item should use for its clip shape and offset
+                val keylineBefore =
+                    keylines.getKeylineBefore(unadjustedCenter)
+                val keylineAfter =
+                    keylines.getKeylineAfter(unadjustedCenter)
+                val progress = getProgress(keylineBefore, keylineAfter, unadjustedCenter)
+                val interpolatedKeyline = lerp(keylineBefore, keylineAfter, progress)
+                val isOutOfKeylineBounds = keylineBefore == keylineAfter
+
+                val centerX =
+                    if (isVertical) size.height / 2f else strategyResult.itemMainAxisSize / 2f
+                val centerY =
+                    if (isVertical) strategyResult.itemMainAxisSize / 2f else size.height / 2f
+                val halfMaskWidth =
+                    if (isVertical) size.width / 2f else interpolatedKeyline.size / 2f
+                val halfMaskHeight =
+                    if (isVertical) interpolatedKeyline.size / 2f else size.height / 2f
+                val maskRect = Rect(
+                    left = centerX - halfMaskWidth,
+                    top = centerY - halfMaskHeight,
+                    right = centerX + halfMaskWidth,
+                    bottom = centerY + halfMaskHeight
+                )
+
+                // Update carousel item info
+                carouselItemInfo.sizeState = interpolatedKeyline.size
+                carouselItemInfo.minSizeState = roundedKeylines.minBy { it.size }.size
+                carouselItemInfo.maxSizeState = roundedKeylines.firstFocal.size
+                carouselItemInfo.maskRectState = maskRect
+
+                // Clip the item
+                clip = true
+                shape = object : Shape {
+                    // TODO: Find a way to use the shape of the item set by the client for each item
+                    // TODO: Allow corner size customization
+                    val roundedCornerShape = RoundedCornerShape(ShapeDefaults.ExtraLarge.topStart)
+                    override fun createOutline(
+                        size: Size,
+                        layoutDirection: LayoutDirection,
+                        density: Density
+                    ): Outline {
+                        val cornerSize =
+                            roundedCornerShape.topStart.toPx(
+                                Size(maskRect.width, maskRect.height),
+                                density
+                            )
+                        val cornerRadius = CornerRadius(cornerSize)
+                        return Outline.Rounded(
+                            RoundRect(
+                                rect = maskRect,
+                                topLeft = cornerRadius,
+                                topRight = cornerRadius,
+                                bottomRight = cornerRadius,
+                                bottomLeft = cornerRadius
+                            )
+                        )
+                    }
+                }
+
+                // After clipping, the items will have white space between them. Translate the
+                // items to pin their edges together
+                var translation = interpolatedKeyline.offset - unadjustedCenter
+                if (isOutOfKeylineBounds) {
+                    // If this item is beyond the first or last keyline, continue to offset the
+                    // item by cutting its unadjustedOffset according to its masked size.
+                    val outOfBoundsOffset =
+                        (unadjustedCenter - interpolatedKeyline.unadjustedOffset) /
+                            interpolatedKeyline.size
+                    translation += outOfBoundsOffset
+                }
+                if (isVertical) {
+                    translationY = translation
+                } else {
+                    translationX = if (isRtl) -translation else translation
+                }
+            })
         }
     }
 }
@@ -544,14 +535,13 @@
 internal fun calculateCurrentScrollOffset(
     state: CarouselState,
     strategy: Strategy,
-    snapPositionMap: IntIntMap
 ): Float {
     val itemSizeWithSpacing = strategy.itemMainAxisSize + strategy.itemSpacing
     val currentItemScrollOffset =
         (state.pagerState.currentPage * itemSizeWithSpacing) +
             (state.pagerState.currentPageOffsetFraction * itemSizeWithSpacing)
     return currentItemScrollOffset -
-        (if (snapPositionMap.size > 0) snapPositionMap[state.pagerState.currentPage] else 0)
+        getSnapPositionOffset(strategy, state.pagerState.currentPage, state.pagerState.pageCount)
 }
 
 /** Returns the max scroll offset given the item count, sizing, and spacing. */
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt
index 1f5f990..0e45d2b 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt
@@ -23,11 +23,14 @@
 import androidx.compose.foundation.pager.PagerState
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.saveable.Saver
 import androidx.compose.runtime.saveable.listSaver
 import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Rect
 
 /**
  * The state that can be used to control all types of carousels.
@@ -133,56 +136,28 @@
      * at a focal position
      */
     val maxSize: Float
-}
 
-/**
- * Gets the start offset of the carousel item from its full size. This offset can be used to pin any
- * carousel item content to the left side of the item (or right if RTL).
- */
-@OptIn(ExperimentalMaterial3Api::class)
-fun CarouselItemInfo.startOffset(): Float {
-    return (maxSize - size) / 2f
-}
-
-/**
- * Gets the end offset of the carousel item from its full size. This offset can be used to pin any
- * carousel item content to the right side of the item (or left if RTL).
- */
-@OptIn(ExperimentalMaterial3Api::class)
-fun CarouselItemInfo.endOffset(): Float {
-    return maxSize - (maxSize - size) / 2f
+    /** The rect by which the carousel item is being clipped. */
+    val maskRect: Rect
 }
 
 @OptIn(ExperimentalMaterial3Api::class)
 internal class CarouselItemInfoImpl : CarouselItemInfo {
 
-    val sizeState = mutableFloatStateOf(0f)
+    var sizeState by mutableFloatStateOf(0f)
+    var minSizeState by mutableFloatStateOf(0f)
+    var maxSizeState by mutableFloatStateOf(0f)
+    var maskRectState by mutableStateOf(Rect.Zero)
+
     override val size: Float
-        get() = sizeState.floatValue
+        get() = sizeState
 
-    val minSizeState = mutableFloatStateOf(0f)
     override val minSize: Float
-        get() = minSizeState.floatValue
+        get() = minSizeState
 
-    val maxSizeState = mutableFloatStateOf(0f)
     override val maxSize: Float
-        get() = maxSizeState.floatValue
+        get() = maxSizeState
 
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other !is CarouselItemInfoImpl) return false
-
-        if (sizeState != other.sizeState) return false
-        if (minSizeState != other.minSizeState) return false
-        if (maxSizeState != other.maxSizeState) return false
-
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = sizeState.hashCode()
-        result = 31 * result + minSizeState.hashCode()
-        result = 31 * result + maxSizeState.hashCode()
-        return result
-    }
+    override val maskRect: Rect
+        get() = maskRectState
 }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineList.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineList.kt
index a0125af..0f2acee 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineList.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineList.kt
@@ -74,7 +74,8 @@
     val pivotIndex: Int = indexOfFirst { it.isPivot }
 
     /** Returns the keyline used to calculate all other keyline offsets and unadjusted offsets. */
-    val pivot: Keyline = get(pivotIndex)
+    val pivot: Keyline
+        get() = get(pivotIndex)
 
     /**
      * Returns the index of the first non-anchor keyline or -1 if the list does not contain a
@@ -86,7 +87,8 @@
      * Returns the first non-anchor [Keyline].
      * @throws [NoSuchElementException] if there are no non-anchor keylines.
      */
-    val firstNonAnchor: Keyline = get(firstNonAnchorIndex)
+    val firstNonAnchor: Keyline
+        get() = get(firstNonAnchorIndex)
 
     /**
      * Returns the index of the last non-anchor keyline or -1 if the list does not contain a
@@ -98,7 +100,8 @@
      * Returns the last non-anchor [Keyline].
      * @throws [NoSuchElementException] if there are no non-anchor keylines.
      */
-    val lastNonAnchor = get(lastNonAnchorIndex)
+    val lastNonAnchor: Keyline
+        get() = get(lastNonAnchorIndex)
 
     /**
      * Returns the index of the first focal keyline or -1 if the list does not contain a
@@ -110,8 +113,9 @@
      * Returns the first focal [Keyline].
      * @throws [NoSuchElementException] if there are no focal keylines.
      */
-    val firstFocal: Keyline = getOrNull(firstFocalIndex)
-        ?: throw NoSuchElementException("All KeylineLists must have at least one focal keyline")
+    val firstFocal: Keyline
+        get() = getOrNull(firstFocalIndex)
+            ?: throw NoSuchElementException("All KeylineLists must have at least one focal keyline")
 
     /**
      * Returns the index of the last focal keyline or -1 if the list does not contain a
@@ -123,8 +127,9 @@
      * Returns the last focal [Keyline].
      * @throws [NoSuchElementException] if there are no focal keylines.
      */
-    val lastFocal = getOrNull(lastFocalIndex)
-        ?: throw NoSuchElementException("All KeylineLists must have at least one focal keyline")
+    val lastFocal: Keyline
+        get() = getOrNull(lastFocalIndex)
+            ?: throw NoSuchElementException("All KeylineLists must have at least one focal keyline")
 
     /**
      * Returns true if the first focal item's left/top is within the visible bounds of the container
@@ -222,8 +227,14 @@
         fastForEach { keyline -> result += 31 * keyline.hashCode() }
         return result
     }
+
+    companion object {
+        val Empty = KeylineList(emptyList())
+    }
 }
 
+internal fun emptyKeylineList() = KeylineList.Empty
+
 /**
  * Returns a [KeylineList] by aligning the focal range relative to the carousel container.
  */
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
index 9caf196..f310d9f 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
@@ -16,55 +16,48 @@
 
 package androidx.compose.material3.carousel
 
-import androidx.collection.IntIntMap
-import androidx.collection.emptyIntIntMap
-import androidx.collection.mutableIntIntMapOf
 import androidx.compose.foundation.gestures.snapping.SnapPosition
 import kotlin.math.max
 import kotlin.math.min
 import kotlin.math.roundToInt
 
 /**
- * Maps each item's position to the first focal keyline in a specific keyline state.
+ * Calculates the offset from the beginning of the carousel container needed to snap to the item
+ * at [itemIndex].
  *
- * The assigned keyline state guarantees the item will be at a focal position (ie. fully unmasked).
- * Keyline states are assigned in order of start state to end state for each item in order, with
- * the default keyline state assigned for extra items in the middle.
+ * This method takes into account the correct keyline list needed to allow the item to be fully
+ * visible and located at a focal position.
  */
-internal fun calculateSnapPositions(strategy: Strategy?, itemCount: Int): IntIntMap {
-    if (strategy == null || !strategy.isValid()) {
-        return emptyIntIntMap()
-    }
-    val map = mutableIntIntMapOf()
-    val defaultKeylines = strategy.defaultKeylines
-    val startKeylineSteps = strategy.startKeylineSteps
-    val endKeylineSteps = strategy.endKeylineSteps
-    val numOfFocalKeylines = defaultKeylines.lastFocalIndex - defaultKeylines.firstFocalIndex
-    val startStepsSize = startKeylineSteps.size + numOfFocalKeylines
-    val endStepsSize = endKeylineSteps.size + numOfFocalKeylines
+internal fun getSnapPositionOffset(strategy: Strategy, itemIndex: Int, itemCount: Int): Int {
+    if (!strategy.isValid) return 0
 
-    for (itemIndex in 0 until itemCount) {
-        map[itemIndex] = (defaultKeylines.firstFocal.unadjustedOffset -
-            strategy.itemMainAxisSize / 2F).roundToInt()
-        if (itemIndex < startStepsSize) {
-            var startIndex = max(0, startStepsSize - 1 - itemIndex)
-            startIndex = min(startKeylineSteps.size - 1, startIndex)
-            val startKeylines = startKeylineSteps[startIndex]
-            map[itemIndex] = (startKeylines.firstFocal.unadjustedOffset -
-                strategy.itemMainAxisSize / 2f).roundToInt()
-        }
-        if (itemCount > numOfFocalKeylines + 1 && itemIndex >= itemCount - endStepsSize) {
-            var endIndex = max(0, itemIndex - itemCount + endStepsSize)
-            endIndex = min(endKeylineSteps.size - 1, endIndex)
-            val endKeylines = endKeylineSteps[endIndex]
-            map[itemIndex] = (endKeylines.firstFocal.unadjustedOffset -
-                strategy.itemMainAxisSize / 2f).roundToInt()
-        }
+    val numOfFocalKeylines = strategy.defaultKeylines.lastFocalIndex -
+        strategy.defaultKeylines.firstFocalIndex
+    val startStepsSize = strategy.startKeylineSteps.size + numOfFocalKeylines
+    val endStepsSize = strategy.endKeylineSteps.size + numOfFocalKeylines
+
+    var offset = (strategy.defaultKeylines.firstFocal.unadjustedOffset -
+        strategy.itemMainAxisSize / 2F).roundToInt()
+
+    if (itemIndex < startStepsSize) {
+        var startIndex = max(0, startStepsSize - 1 - itemIndex)
+        startIndex = min(strategy.startKeylineSteps.size - 1, startIndex)
+        val startKeylines = strategy.startKeylineSteps[startIndex]
+        offset = (startKeylines.firstFocal.unadjustedOffset -
+            strategy.itemMainAxisSize / 2f).roundToInt()
     }
-    return map
+    if (itemCount > numOfFocalKeylines + 1 && itemIndex >= itemCount - endStepsSize) {
+        var endIndex = max(0, itemIndex - itemCount + endStepsSize)
+        endIndex = min(strategy.endKeylineSteps.size - 1, endIndex)
+        val endKeylines = strategy.endKeylineSteps[endIndex]
+        offset = (endKeylines.firstFocal.unadjustedOffset -
+            strategy.itemMainAxisSize / 2f).roundToInt()
+    }
+
+    return offset
 }
 
-internal fun KeylineSnapPosition(snapPositions: IntIntMap): SnapPosition =
+internal fun KeylineSnapPosition(pageSize: CarouselPageSize): SnapPosition =
     object : SnapPosition {
         override fun position(
             layoutSize: Int,
@@ -74,6 +67,6 @@
             itemIndex: Int,
             itemCount: Int
         ): Int {
-            return if (snapPositions.size > 0) snapPositions[itemIndex] else 0
+            return getSnapPositionOffset(pageSize.strategy, itemIndex, itemCount)
         }
     }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
index 6b47ff5..ecb6b00 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
@@ -52,9 +52,9 @@
     itemCount: Int,
     minSmallItemSize: Float = with(density) { CarouselDefaults.MinSmallItemSize.toPx() },
     maxSmallItemSize: Float = with(density) { CarouselDefaults.MaxSmallItemSize.toPx() },
-): KeylineList? {
+): KeylineList {
     if (carouselMainAxisSize == 0f || preferredItemSize == 0f) {
-        return null
+        return emptyKeylineList()
     }
 
     var smallCounts: IntArray = intArrayOf(1)
@@ -130,7 +130,7 @@
     }
 
     if (arrangement == null) {
-        return null
+        return emptyKeylineList()
     }
 
     return createLeftAlignedKeylineList(
@@ -180,9 +180,9 @@
     carouselMainAxisSize: Float,
     itemSize: Float,
     itemSpacing: Float,
-): KeylineList? {
+): KeylineList {
     if (carouselMainAxisSize == 0f || itemSize == 0f) {
-        return null
+        return emptyKeylineList()
     }
 
     val largeItemSize = min(itemSize + itemSpacing, carouselMainAxisSize)
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
index 92a5bba..d548b78 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
@@ -16,12 +16,8 @@
 
 package androidx.compose.material3.carousel
 
-import androidx.annotation.VisibleForTesting
 import androidx.collection.FloatList
 import androidx.collection.mutableFloatListOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.util.fastFilter
 import androidx.compose.ui.util.fastForEach
 import androidx.compose.ui.util.fastMapIndexed
@@ -31,132 +27,112 @@
 import kotlin.math.roundToInt
 
 /**
- * A class responsible for supplying carousel with a [KeylineList] that is corrected for scroll
- * offset, layout direction, and snapping behaviors.
+ * An immutable class responsible for supplying carousel with a [KeylineList] that is corrected for
+ * scroll offset, layout direction, and snapping behaviors.
  *
- * Strategy is created using a [keylineList] block that returns a default [KeylineList]. This is
- * the list of keylines that define how items should be arranged, from left-to-right,
- * to achieve the carousel's desired appearance. For example, a start-aligned large
- * item, followed by a medium and a small item for a multi-browse carousel. Or a small item,
- * a center-aligned large item, and a small item for a centered hero carousel. Strategy will
- * use the [KeylineList] returned from the [keylineList] block to then derive new scroll, and layout
- * direction-aware [KeylineList]s and supply them for use by carousel. For example, when a
- * device is running in a right-to-left layout direction, Strategy will handle reversing the default
- * [KeylineList]. Or if the default keylines use a center-aligned large item, Strategy will shift
- * the large item to the start or end of the screen when the carousel is scrolled to the start or
- * end of the list, letting all items become large without having them detach from the edges of
- * the scroll container.
- *
- * @param keylineList a function that generates default keylines for this strategy based on the
- * carousel's available space. This function will be called anytime availableSpace changes.
+ * @param defaultKeylines the keylines that define how items should be arranged in their default
+ * state
+ * @param startKeylineSteps a list of [KeylineList]s that move the focal range from its position
+ * in [defaultKeylines] to the start of the carousel container, one keyline at a time per step
+ * @param endKeylineSteps a list of [KeylineList]s that move the focal range from its position in
+ * [defaultKeylines] to the end of the carousel container, one keyline at a time per step.
+ * [endKeylineSteps] and whose value is the percentage of [endShiftDistance] that should be
+ * scrolled when the end step is used.
+ * @param availableSpace the available space in the main axis
+ * @param itemSpacing the spacing between each item
+ * @param beforeContentPadding the padding preceding the first item in the list
+ * @param afterContentPadding the padding proceeding the last item in the list
  */
-internal class Strategy(
-    private val keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList?
+internal class Strategy private constructor(
+    val defaultKeylines: KeylineList,
+    val startKeylineSteps: List<KeylineList>,
+    val endKeylineSteps: List<KeylineList>,
+    val availableSpace: Float,
+    val itemSpacing: Float,
+    val beforeContentPadding: Float,
+    val afterContentPadding: Float,
 ) {
 
-    /** The keylines generated from the [keylineList] block. */
-    internal lateinit var defaultKeylines: KeylineList
     /**
-     * A list of [KeylineList]s that move the focal range from its position in [defaultKeylines]
-     * to the start of the carousel container, one keyline at a time.
+     * Creates a new [Strategy] for a keyline list and set of carousel container parameters.
+     *
+     * The [defaultKeylines] are a list of keylines that defines how items should be arranged,
+     * from left-to-right (or top-to-bottom), to achieve the carousel's desired appearance. For
+     * example, a start-aligned large item, followed by a medium and a small item for a
+     * multi-browse carousel. Or a small item, a center-aligned large item, and a small item for
+     * a centered hero carousel. This method will use the [defaultKeylines] to then derive new
+     * scroll and layout direction-aware [KeylineList]s to be used by carousel. For example, when
+     * a device is running in a right-to-left layout direction, Strategy will handle reversing
+     * the default [KeylineList]. Or if the default keylines use a center-aligned large item,
+     * Strategy will generate additional KeylineLists that handle shifting the large item to the
+     * start or end of the screen when the carousel is scrolled to the start or end of the list,
+     * letting all items become large without having them detach from the edges of the
+     * scroll container.
+     *
+     * @param defaultKeylines a default [KeylineList] that represents the arrangement
+     * of items in a left-to-right (or top-to-bottom) layout.
+     * @param availableSpace the size of the carousel container in scrolling axis
+     * @param beforeContentPadding the padding to add before the list content
+     * @param afterContentPadding the padding to add after the list content
      */
-    internal lateinit var startKeylineSteps: List<KeylineList>
-    /**
-     * A list of [KeylineList]s that move the focal range from its position in [defaultKeylines]
-     * to the end of the carousel container, one keyline at a time.
-     */
-    internal lateinit var endKeylineSteps: List<KeylineList>
+    constructor(
+        defaultKeylines: KeylineList,
+        availableSpace: Float,
+        itemSpacing: Float,
+        beforeContentPadding: Float,
+        afterContentPadding: Float
+    ) : this(
+        defaultKeylines = defaultKeylines,
+        startKeylineSteps = getStartKeylineSteps(
+            defaultKeylines,
+            availableSpace,
+            itemSpacing,
+            beforeContentPadding
+        ),
+        endKeylineSteps = getEndKeylineSteps(
+            defaultKeylines,
+            availableSpace,
+            itemSpacing,
+            afterContentPadding
+        ),
+        availableSpace = availableSpace,
+        itemSpacing = itemSpacing,
+        beforeContentPadding = beforeContentPadding,
+        afterContentPadding = afterContentPadding,
+    )
+
     /** The scroll distance needed to move through all steps in [startKeylineSteps]. */
-    private var startShiftDistance: Float = 0f
+    private val startShiftDistance = getStartShiftDistance(startKeylineSteps, beforeContentPadding)
     /** The scroll distance needed to move through all steps in [endKeylineSteps]. */
-    private var endShiftDistance: Float = 0f
+    private val endShiftDistance = getEndShiftDistance(endKeylineSteps, afterContentPadding)
     /**
      * A list of floats whose index aligns with a [KeylineList] from [startKeylineSteps] and
      * whose value is the percentage of [startShiftDistance] that should be scrolled when the
      * start step is used.
      */
-    private lateinit var startShiftPoints: FloatList
+    private val startShiftPoints = getStepInterpolationPoints(
+        startShiftDistance,
+        startKeylineSteps,
+        true
+    )
     /**
      * A list of floats whose index aligns with a [KeylineList] from [endKeylineSteps] and
      * whose value is the percentage of [endShiftDistance] that should be scrolled when the
      * end step is used.
      */
-    private lateinit var endShiftPoints: FloatList
+    private val endShiftPoints = getStepInterpolationPoints(
+        endShiftDistance,
+        endKeylineSteps,
+        false
+    )
 
-    /** The available space in the main axis used in the most recent call to [apply]. */
-    internal var availableSpace: Float = 0f
-    /** The spacing between each item. */
-    internal var itemSpacing: Float = 0f
-    internal var beforeContentPadding: Float = 0f
-    internal var afterContentPadding: Float = 0f
     /** The size of items when in focus and fully unmasked. */
-    internal var itemMainAxisSize by mutableFloatStateOf(0f)
+    val itemMainAxisSize: Float
+        get() = defaultKeylines.firstFocal.size
 
-    /**
-     * Whether this strategy holds a valid set of keylines that are ready for use.
-     *
-     * This is true after [apply] has been called and the [keylineList] block has returned a
-     * non-null [KeylineList].
-     */
-    fun isValid() = itemMainAxisSize > 0f
-
-    /**
-     * Updates this [Strategy] based on carousel's main axis available space.
-     *
-     * This method must be called before a strategy can be used by carousel.
-     *
-     * @param availableSpace the size of the carousel container in scrolling axis
-     * @param beforeContentPadding the padding to add before the list content
-     * @param afterContentPadding the padding to add after the list content
-     */
-    internal fun apply(
-        availableSpace: Float,
-        itemSpacing: Float,
-        beforeContentPadding: Float,
-        afterContentPadding: Float
-    ): Strategy {
-        // Skip computing new keylines and updating this strategy if
-        // available space has not changed.
-        if (this.availableSpace == availableSpace && this.itemSpacing == itemSpacing) {
-            return this
-        }
-
-        val keylineList = keylineList.invoke(availableSpace, itemSpacing) ?: return this
-        val startKeylineSteps =
-            getStartKeylineSteps(keylineList, availableSpace, itemSpacing, beforeContentPadding)
-        val endKeylineSteps =
-            getEndKeylineSteps(keylineList, availableSpace, itemSpacing, afterContentPadding)
-
-        // TODO: Update this to use the first/last focal keylines to calculate shift?
-        val startShiftDistance = max(startKeylineSteps.last().first().unadjustedOffset -
-            keylineList.first().unadjustedOffset, beforeContentPadding)
-        val endShiftDistance = max(keylineList.last().unadjustedOffset -
-            endKeylineSteps.last().last().unadjustedOffset, afterContentPadding)
-
-        this.defaultKeylines = keylineList
-        this.defaultKeylines = keylineList
-        this.startKeylineSteps = startKeylineSteps
-        this.endKeylineSteps = endKeylineSteps
-        this.startShiftDistance = startShiftDistance
-        this.endShiftDistance = endShiftDistance
-        this.startShiftPoints = getStepInterpolationPoints(
-            startShiftDistance,
-            startKeylineSteps,
-            true
-        )
-        this.endShiftPoints = getStepInterpolationPoints(
-            endShiftDistance,
-            endKeylineSteps,
-            false
-        )
-        this.availableSpace = availableSpace
-        this.itemSpacing = itemSpacing
-        this.beforeContentPadding = beforeContentPadding
-        this.afterContentPadding = afterContentPadding
-        this.itemMainAxisSize = defaultKeylines.firstFocal.size
-
-        return this
-    }
+    /** True if this strategy contains a valid arrangement of keylines for a valid container */
+    val isValid: Boolean =
+        defaultKeylines.isNotEmpty() && availableSpace != 0f && itemMainAxisSize != 0f
 
     /**
      * Returns the [KeylineList] that should be used for the current [scrollOffset].
@@ -225,23 +201,14 @@
         )
     }
 
-    @VisibleForTesting
-    internal fun getEndKeylines(): KeylineList {
-        return endKeylineSteps.last()
-    }
-
-    @VisibleForTesting
-    internal fun getStartKeylines(): KeylineList {
-        return startKeylineSteps.last()
-    }
-
     override fun equals(other: Any?): Boolean {
         if (this === other) return true
         if (other !is Strategy) return false
-        // If neither strategy is valid, they should be considered equal
-        if (!isValid() && !other.isValid()) return true
 
-        if (isValid() != other.isValid()) return false
+        // If neither strategy is valid, they should be considered equal
+        if (!isValid && !other.isValid) return true
+
+        if (isValid != other.isValid) return false
         if (availableSpace != other.availableSpace) return false
         if (itemSpacing != other.itemSpacing) return false
         if (beforeContentPadding != other.beforeContentPadding) return false
@@ -259,9 +226,9 @@
     }
 
     override fun hashCode(): Int {
-        if (!isValid()) return isValid().hashCode()
+        if (!isValid) return isValid.hashCode()
 
-        var result = isValid().hashCode()
+        var result = isValid.hashCode()
         result = 31 * result + availableSpace.hashCode()
         result = 31 * result + itemSpacing.hashCode()
         result = 31 * result + beforeContentPadding.hashCode()
@@ -276,352 +243,387 @@
     }
 
     companion object {
+        val Empty = Strategy(
+            defaultKeylines = emptyKeylineList(),
+            startKeylineSteps = emptyList(),
+            endKeylineSteps = emptyList(),
+            availableSpace = 0f,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f,
+        )
+    }
+}
 
-        /**
-         * Generates discreet steps which move the focal range from its original position until
-         * it reaches the start of the carousel container.
-         *
-         * Each step can only move the focal range by one keyline at a time to ensure every
-         * item in the list passes through the focal range. Each step removes the keyline at the
-         * start of the container and re-inserts it after the focal range in an order that retains
-         * visual balance. This is repeated until the first focal keyline is at the start of the
-         * container. Re-inserting keylines after the focal range in a balanced way is done by
-         * looking at the size of they keyline next to the keyline that is being re-positioned
-         * and finding a match on the other side of the focal range.
-         *
-         * The first state in the returned list is always the default [KeylineList] while
-         * the last state will be the start state or the state that has the focal range at the
-         * beginning of the carousel.
-         */
-        private fun getStartKeylineSteps(
-            defaultKeylines: KeylineList,
-            carouselMainAxisSize: Float,
-            itemSpacing: Float,
-            beforeContentPadding: Float
-        ): List<KeylineList> {
-            val steps: MutableList<KeylineList> = mutableListOf()
-            steps.add(defaultKeylines)
+/**
+ * Returns the total scroll offset needed to move through the entire list of [startKeylineSteps].
+ */
+private fun getStartShiftDistance(
+    startKeylineSteps: List<KeylineList>,
+    beforeContentPadding: Float
+): Float {
+    if (startKeylineSteps.isEmpty()) return 0f
+    return max(startKeylineSteps.last().first().unadjustedOffset -
+        startKeylineSteps.first().first().unadjustedOffset, beforeContentPadding)
+}
+/**
+ * Returns the total scroll offset needed to move through the entire list of [endKeylineSteps].
+ */
+private fun getEndShiftDistance(
+    endKeylineSteps: List<KeylineList>,
+    afterContentPadding: Float
+): Float {
+    if (endKeylineSteps.isEmpty()) return 0f
+    return max(endKeylineSteps.first().last().unadjustedOffset -
+        endKeylineSteps.last().last().unadjustedOffset, afterContentPadding)
+}
 
-            if (defaultKeylines.isFirstFocalItemAtStartOfContainer()) {
-                if (beforeContentPadding != 0f) {
-                    steps.add(
-                        createShiftedKeylineListForContentPadding(
-                            defaultKeylines,
-                            carouselMainAxisSize,
-                            itemSpacing,
-                            beforeContentPadding,
-                            defaultKeylines.firstFocal,
-                            defaultKeylines.firstFocalIndex
-                        )
-                    )
-                }
-                return steps
-            }
+/**
+ * Generates discreet steps which move the focal range from its original position until
+ * it reaches the start of the carousel container.
+ *
+ * Each step can only move the focal range by one keyline at a time to ensure every
+ * item in the list passes through the focal range. Each step removes the keyline at the
+ * start of the container and re-inserts it after the focal range in an order that retains
+ * visual balance. This is repeated until the first focal keyline is at the start of the
+ * container. Re-inserting keylines after the focal range in a balanced way is done by
+ * looking at the size of they keyline next to the keyline that is being re-positioned
+ * and finding a match on the other side of the focal range.
+ *
+ * The first state in the returned list is always the default [KeylineList] while
+ * the last state will be the start state or the state that has the focal range at the
+ * beginning of the carousel.
+ */
+private fun getStartKeylineSteps(
+    defaultKeylines: KeylineList,
+    carouselMainAxisSize: Float,
+    itemSpacing: Float,
+    beforeContentPadding: Float
+): List<KeylineList> {
+    if (defaultKeylines.isEmpty()) return emptyList()
 
-            val startIndex = defaultKeylines.firstNonAnchorIndex
-            val endIndex = defaultKeylines.firstFocalIndex
-            val numberOfSteps = endIndex - startIndex
+    val steps: MutableList<KeylineList> = mutableListOf()
+    steps.add(defaultKeylines)
 
-            // If there are no steps but we need to account for a cutoff, create a
-            // list of keylines shifted for the cutoff.
-            if (numberOfSteps <= 0 && defaultKeylines.firstFocal.cutoff > 0) {
-                steps.add(
-                    moveKeylineAndCreateShiftedKeylineList(
-                        from = defaultKeylines,
-                        srcIndex = 0,
-                        dstIndex = 0,
-                        carouselMainAxisSize = carouselMainAxisSize,
-                        itemSpacing = itemSpacing
-                    )
-                )
-                return steps
-            }
-
-            var i = 0
-            while (i < numberOfSteps) {
-                val prevStep = steps.last()
-                val originalItemIndex = startIndex + i
-                var dstIndex = defaultKeylines.lastIndex
-                if (originalItemIndex > 0) {
-                    val originalNeighborBeforeSize = defaultKeylines[originalItemIndex - 1].size
-                    dstIndex = prevStep.firstIndexAfterFocalRangeWithSize(
-                        originalNeighborBeforeSize
-                    ) - 1
-                }
-
-                steps.add(
-                    moveKeylineAndCreateShiftedKeylineList(
-                        from = prevStep,
-                        srcIndex = defaultKeylines.firstNonAnchorIndex,
-                        dstIndex = dstIndex,
-                        carouselMainAxisSize = carouselMainAxisSize,
-                        itemSpacing = itemSpacing
-                    )
-                )
-                i++
-            }
-
-            if (beforeContentPadding != 0f) {
-                steps[steps.lastIndex] = createShiftedKeylineListForContentPadding(
-                    steps.last(),
+    if (defaultKeylines.isFirstFocalItemAtStartOfContainer()) {
+        if (beforeContentPadding != 0f) {
+            steps.add(
+                createShiftedKeylineListForContentPadding(
+                    defaultKeylines,
                     carouselMainAxisSize,
                     itemSpacing,
                     beforeContentPadding,
-                    steps.last().firstFocal,
-                    steps.last().firstFocalIndex
+                    defaultKeylines.firstFocal,
+                    defaultKeylines.firstFocalIndex
                 )
-            }
-
-            return steps
-        }
-
-        /**
-         * Generates discreet steps which move the focal range from its original position until
-         * it reaches the end of the carousel container.
-         *
-         * Each step can only move the focal range by one keyline at a time to ensure every
-         * item in the list passes through the focal range. Each step removes the keyline at the
-         * end of the container and re-inserts it before the focal range in an order that retains
-         * visual balance. This is repeated until the last focal keyline is at the start of the
-         * container. Re-inserting keylines before the focal range in a balanced way is done by
-         * looking at the size of they keyline next to the keyline that is being re-positioned
-         * and finding a match on the other side of the focal range.
-         *
-         * The first state in the returned list is always the default [KeylineList] while
-         * the last state will be the end state or the state that has the focal range at the
-         * end of the carousel.
-         */
-        private fun getEndKeylineSteps(
-            defaultKeylines: KeylineList,
-            carouselMainAxisSize: Float,
-            itemSpacing: Float,
-            afterContentPadding: Float
-        ): List<KeylineList> {
-            val steps: MutableList<KeylineList> = mutableListOf()
-            steps.add(defaultKeylines)
-
-            if (defaultKeylines.isLastFocalItemAtEndOfContainer(carouselMainAxisSize)) {
-                if (afterContentPadding != 0f) {
-                    steps.add(createShiftedKeylineListForContentPadding(
-                        defaultKeylines,
-                        carouselMainAxisSize,
-                        itemSpacing,
-                        -afterContentPadding,
-                        defaultKeylines.lastFocal,
-                        defaultKeylines.lastFocalIndex
-                    ))
-                }
-                return steps
-            }
-
-            val startIndex = defaultKeylines.lastFocalIndex
-            val endIndex = defaultKeylines.lastNonAnchorIndex
-            val numberOfSteps = endIndex - startIndex
-
-            // If there are no steps but we need to account for a cutoff, create a
-            // list of keylines shifted for the cutoff.
-            if (numberOfSteps <= 0 && defaultKeylines.lastFocal.cutoff > 0) {
-                steps.add(
-                    moveKeylineAndCreateShiftedKeylineList(
-                        from = defaultKeylines,
-                        srcIndex = 0,
-                        dstIndex = 0,
-                        carouselMainAxisSize = carouselMainAxisSize,
-                        itemSpacing = itemSpacing
-                    )
-                )
-                return steps
-            }
-
-            var i = 0
-            while (i < numberOfSteps) {
-                val prevStep = steps.last()
-                val originalItemIndex = endIndex - i
-                var dstIndex = 0
-
-                if (originalItemIndex < defaultKeylines.lastIndex) {
-                    val originalNeighborAfterSize = defaultKeylines[originalItemIndex + 1].size
-                    dstIndex = prevStep.lastIndexBeforeFocalRangeWithSize(
-                        originalNeighborAfterSize
-                    ) + 1
-                }
-
-                val keylines = moveKeylineAndCreateShiftedKeylineList(
-                    from = prevStep,
-                    srcIndex = defaultKeylines.lastNonAnchorIndex,
-                    dstIndex = dstIndex,
-                    carouselMainAxisSize = carouselMainAxisSize,
-                    itemSpacing = itemSpacing
-                )
-                steps.add(keylines)
-                i++
-            }
-
-            if (afterContentPadding != 0f) {
-                steps[steps.lastIndex] = createShiftedKeylineListForContentPadding(
-                    steps.last(),
-                    carouselMainAxisSize,
-                    itemSpacing,
-                    -afterContentPadding,
-                    steps.last().lastFocal,
-                    steps.last().lastFocalIndex
-                )
-            }
-
-            return steps
-        }
-
-        /**
-         * Returns a new [KeylineList] identical to [from] but with each keyline's offset shifted
-         * by [contentPadding].
-         */
-        private fun createShiftedKeylineListForContentPadding(
-            from: KeylineList,
-            carouselMainAxisSize: Float,
-            itemSpacing: Float,
-            contentPadding: Float,
-            pivot: Keyline,
-            pivotIndex: Int
-        ): KeylineList {
-            val numberOfNonAnchorKeylines = from.fastFilter { !it.isAnchor }.count()
-            val sizeReduction = contentPadding / numberOfNonAnchorKeylines
-            // Let keylineListOf create a new keyline list with offsets adjusted for each item's
-            // reduction in size
-            val newKeylines = keylineListOf(
-                carouselMainAxisSize = carouselMainAxisSize,
-                itemSpacing = itemSpacing,
-                pivotIndex = pivotIndex,
-                pivotOffset = pivot.offset - (sizeReduction / 2f) + contentPadding
-            ) {
-                from.fastForEach { k -> add(k.size - abs(sizeReduction), k.isAnchor) }
-            }
-
-            // Then reset each item's unadjusted offset back to their original value from the
-            // incoming keyline list. This is necessary because Pager will still be laying out items
-            // end-to-end with the original page size and not the new reduced size.
-            return KeylineList(
-                newKeylines.fastMapIndexed { i, k ->
-                    k.copy(
-                        unadjustedOffset = from[i].unadjustedOffset
-                    )
-                }
             )
         }
-
-        /**
-         * Returns a new [KeylineList] where the keyline at [srcIndex] is moved to [dstIndex] and
-         * with updated pivot and offsets that reflect any change in focal shift.
-         */
-        private fun moveKeylineAndCreateShiftedKeylineList(
-            from: KeylineList,
-            srcIndex: Int,
-            dstIndex: Int,
-            carouselMainAxisSize: Float,
-            itemSpacing: Float
-        ): KeylineList {
-            // -1 if the pivot is shifting left/top, 1 if shifting right/bottom
-            val pivotDir = if (srcIndex > dstIndex) 1 else -1
-            val pivotDelta = (from[srcIndex].size - from[srcIndex].cutoff + itemSpacing) * pivotDir
-            val newPivotIndex = from.pivotIndex + pivotDir
-            val newPivotOffset = from.pivot.offset + pivotDelta
-            return keylineListOf(carouselMainAxisSize, itemSpacing, newPivotIndex, newPivotOffset) {
-                from.toMutableList()
-                    .move(srcIndex, dstIndex)
-                    .fastForEach { k -> add(k.size, k.isAnchor) }
-            }
-        }
-
-        /**
-         * Creates and returns a list of float values containing points between 0 and 1 that
-         * represent interpolation values for when the [KeylineList] at the corresponding index in
-         * [steps] should be visible.
-         *
-         * For example, if [steps] has a size of 4, this method will return an array of 4 float
-         * values that could look like [0, .33, .66, 1]. When interpolating through a list of
-         * [KeylineList]s, an interpolation value will be between 0-1. This interpolation will be
-         * used to find the range it falls within from this method's returned value. If
-         * interpolation is .25, that would fall between the 0 and .33, the 0th and 1st indices
-         * of the float array. Meaning the 0th and 1st items from [steps] should be the current
-         * [KeylineList]s being interpolated. This is an example with equally distributed values
-         * but these values will typically be unequally distributed since their size depends on
-         * the distance keylines shift between each step.
-         *
-         * @see [lerp] for more details on how interpolation points are used
-         * @see [getKeylineListForScrollOffset] for more details on how interpolation points
-         * are used
-         *
-         * @param totalShiftDistance the total distance keylines will shift between the first and
-         * last [KeylineList] of [steps]
-         * @param steps the steps to find interpolation points for
-         * @param isShiftingLeft true if this method should find interpolation points for shifting
-         * keylines to the left/top of a carousel, false if this method should find interpolation
-         * points for shifting keylines to the right/bottom of a carousel
-         * @return a list of floats, equal in size to [steps] that contains points between 0-1
-         * that align with when a [KeylineList] from [steps should be shown for a 0-1
-         * interpolation value
-         */
-        private fun getStepInterpolationPoints(
-            totalShiftDistance: Float,
-            steps: List<KeylineList>,
-            isShiftingLeft: Boolean
-        ): FloatList {
-            val points = mutableFloatListOf(0f)
-            if (totalShiftDistance == 0f) {
-                return points
-            }
-
-            (1 until steps.size).map { i ->
-                val prevKeylines = steps[i - 1]
-                val currKeylines = steps[i]
-                val distanceShifted = if (isShiftingLeft) {
-                    currKeylines.first().unadjustedOffset - prevKeylines.first().unadjustedOffset
-                } else {
-                    prevKeylines.last().unadjustedOffset - currKeylines.last().unadjustedOffset
-                }
-                val stepPercentage = distanceShifted / totalShiftDistance
-                val point = if (i == steps.lastIndex) 1f else points[i - 1] + stepPercentage
-                points.add(point)
-            }
-            return points
-        }
-
-        private data class ShiftPointRange(
-            val fromStepIndex: Int,
-            val toStepIndex: Int,
-            val steppedInterpolation: Float
-        )
-
-        private fun getShiftPointRange(
-            stepsCount: Int,
-            shiftPoint: FloatList,
-            interpolation: Float
-        ): ShiftPointRange {
-            var lowerBounds = shiftPoint[0]
-            (1 until stepsCount).forEach { i ->
-                val upperBounds = shiftPoint[i]
-                if (interpolation <= upperBounds) {
-                    return ShiftPointRange(
-                        fromStepIndex = i - 1,
-                        toStepIndex = i,
-                        steppedInterpolation = lerp(0f, 1f, lowerBounds, upperBounds, interpolation)
-                    )
-                }
-                lowerBounds = upperBounds
-            }
-            return ShiftPointRange(
-                fromStepIndex = 0,
-                toStepIndex = 0,
-                steppedInterpolation = 0f
-            )
-        }
-
-        private fun MutableList<Keyline>.move(srcIndex: Int, dstIndex: Int): MutableList<Keyline> {
-            val keyline = get(srcIndex)
-            removeAt(srcIndex)
-            add(dstIndex, keyline)
-            return this
-        }
+        return steps
     }
+
+    val startIndex = defaultKeylines.firstNonAnchorIndex
+    val endIndex = defaultKeylines.firstFocalIndex
+    val numberOfSteps = endIndex - startIndex
+
+    // If there are no steps but we need to account for a cutoff, create a
+    // list of keylines shifted for the cutoff.
+    if (numberOfSteps <= 0 && defaultKeylines.firstFocal.cutoff > 0) {
+        steps.add(
+            moveKeylineAndCreateShiftedKeylineList(
+                from = defaultKeylines,
+                srcIndex = 0,
+                dstIndex = 0,
+                carouselMainAxisSize = carouselMainAxisSize,
+                itemSpacing = itemSpacing
+            )
+        )
+        return steps
+    }
+
+    var i = 0
+    while (i < numberOfSteps) {
+        val prevStep = steps.last()
+        val originalItemIndex = startIndex + i
+        var dstIndex = defaultKeylines.lastIndex
+        if (originalItemIndex > 0) {
+            val originalNeighborBeforeSize = defaultKeylines[originalItemIndex - 1].size
+            dstIndex = prevStep.firstIndexAfterFocalRangeWithSize(
+                originalNeighborBeforeSize
+            ) - 1
+        }
+
+        steps.add(
+            moveKeylineAndCreateShiftedKeylineList(
+                from = prevStep,
+                srcIndex = defaultKeylines.firstNonAnchorIndex,
+                dstIndex = dstIndex,
+                carouselMainAxisSize = carouselMainAxisSize,
+                itemSpacing = itemSpacing
+            )
+        )
+        i++
+    }
+
+    if (beforeContentPadding != 0f) {
+        steps[steps.lastIndex] = createShiftedKeylineListForContentPadding(
+            steps.last(),
+            carouselMainAxisSize,
+            itemSpacing,
+            beforeContentPadding,
+            steps.last().firstFocal,
+            steps.last().firstFocalIndex
+        )
+    }
+
+    return steps
+}
+
+/**
+ * Generates discreet steps which move the focal range from its original position until
+ * it reaches the end of the carousel container.
+ *
+ * Each step can only move the focal range by one keyline at a time to ensure every
+ * item in the list passes through the focal range. Each step removes the keyline at the
+ * end of the container and re-inserts it before the focal range in an order that retains
+ * visual balance. This is repeated until the last focal keyline is at the start of the
+ * container. Re-inserting keylines before the focal range in a balanced way is done by
+ * looking at the size of they keyline next to the keyline that is being re-positioned
+ * and finding a match on the other side of the focal range.
+ *
+ * The first state in the returned list is always the default [KeylineList] while
+ * the last state will be the end state or the state that has the focal range at the
+ * end of the carousel.
+ */
+private fun getEndKeylineSteps(
+    defaultKeylines: KeylineList,
+    carouselMainAxisSize: Float,
+    itemSpacing: Float,
+    afterContentPadding: Float
+): List<KeylineList> {
+    if (defaultKeylines.isEmpty()) return emptyList()
+    val steps: MutableList<KeylineList> = mutableListOf()
+    steps.add(defaultKeylines)
+
+    if (defaultKeylines.isLastFocalItemAtEndOfContainer(carouselMainAxisSize)) {
+        if (afterContentPadding != 0f) {
+            steps.add(createShiftedKeylineListForContentPadding(
+                defaultKeylines,
+                carouselMainAxisSize,
+                itemSpacing,
+                -afterContentPadding,
+                defaultKeylines.lastFocal,
+                defaultKeylines.lastFocalIndex
+            ))
+        }
+        return steps
+    }
+
+    val startIndex = defaultKeylines.lastFocalIndex
+    val endIndex = defaultKeylines.lastNonAnchorIndex
+    val numberOfSteps = endIndex - startIndex
+
+    // If there are no steps but we need to account for a cutoff, create a
+    // list of keylines shifted for the cutoff.
+    if (numberOfSteps <= 0 && defaultKeylines.lastFocal.cutoff > 0) {
+        steps.add(
+            moveKeylineAndCreateShiftedKeylineList(
+                from = defaultKeylines,
+                srcIndex = 0,
+                dstIndex = 0,
+                carouselMainAxisSize = carouselMainAxisSize,
+                itemSpacing = itemSpacing
+            )
+        )
+        return steps
+    }
+
+    var i = 0
+    while (i < numberOfSteps) {
+        val prevStep = steps.last()
+        val originalItemIndex = endIndex - i
+        var dstIndex = 0
+
+        if (originalItemIndex < defaultKeylines.lastIndex) {
+            val originalNeighborAfterSize = defaultKeylines[originalItemIndex + 1].size
+            dstIndex = prevStep.lastIndexBeforeFocalRangeWithSize(
+                originalNeighborAfterSize
+            ) + 1
+        }
+
+        val keylines = moveKeylineAndCreateShiftedKeylineList(
+            from = prevStep,
+            srcIndex = defaultKeylines.lastNonAnchorIndex,
+            dstIndex = dstIndex,
+            carouselMainAxisSize = carouselMainAxisSize,
+            itemSpacing = itemSpacing
+        )
+        steps.add(keylines)
+        i++
+    }
+
+    if (afterContentPadding != 0f) {
+        steps[steps.lastIndex] = createShiftedKeylineListForContentPadding(
+            steps.last(),
+            carouselMainAxisSize,
+            itemSpacing,
+            -afterContentPadding,
+            steps.last().lastFocal,
+            steps.last().lastFocalIndex
+        )
+    }
+
+    return steps
+}
+
+/**
+ * Returns a new [KeylineList] identical to [from] but with each keyline's offset shifted
+ * by [contentPadding].
+ */
+private fun createShiftedKeylineListForContentPadding(
+    from: KeylineList,
+    carouselMainAxisSize: Float,
+    itemSpacing: Float,
+    contentPadding: Float,
+    pivot: Keyline,
+    pivotIndex: Int
+): KeylineList {
+    val numberOfNonAnchorKeylines = from.fastFilter { !it.isAnchor }.count()
+    val sizeReduction = contentPadding / numberOfNonAnchorKeylines
+    // Let keylineListOf create a new keyline list with offsets adjusted for each item's
+    // reduction in size
+    val newKeylines = keylineListOf(
+        carouselMainAxisSize = carouselMainAxisSize,
+        itemSpacing = itemSpacing,
+        pivotIndex = pivotIndex,
+        pivotOffset = pivot.offset - (sizeReduction / 2f) + contentPadding
+    ) {
+        from.fastForEach { k -> add(k.size - abs(sizeReduction), k.isAnchor) }
+    }
+
+    // Then reset each item's unadjusted offset back to their original value from the
+    // incoming keyline list. This is necessary because Pager will still be laying out items
+    // end-to-end with the original page size and not the new reduced size.
+    return KeylineList(
+        newKeylines.fastMapIndexed { i, k ->
+            k.copy(
+                unadjustedOffset = from[i].unadjustedOffset
+            )
+        }
+    )
+}
+
+/**
+ * Returns a new [KeylineList] where the keyline at [srcIndex] is moved to [dstIndex] and
+ * with updated pivot and offsets that reflect any change in focal shift.
+ */
+private fun moveKeylineAndCreateShiftedKeylineList(
+    from: KeylineList,
+    srcIndex: Int,
+    dstIndex: Int,
+    carouselMainAxisSize: Float,
+    itemSpacing: Float
+): KeylineList {
+    // -1 if the pivot is shifting left/top, 1 if shifting right/bottom
+    val pivotDir = if (srcIndex > dstIndex) 1 else -1
+    val pivotDelta = (from[srcIndex].size - from[srcIndex].cutoff + itemSpacing) * pivotDir
+    val newPivotIndex = from.pivotIndex + pivotDir
+    val newPivotOffset = from.pivot.offset + pivotDelta
+    return keylineListOf(carouselMainAxisSize, itemSpacing, newPivotIndex, newPivotOffset) {
+        from.toMutableList()
+            .move(srcIndex, dstIndex)
+            .fastForEach { k -> add(k.size, k.isAnchor) }
+    }
+}
+
+/**
+ * Creates and returns a list of float values containing points between 0 and 1 that
+ * represent interpolation values for when the [KeylineList] at the corresponding index in
+ * [steps] should be visible.
+ *
+ * For example, if [steps] has a size of 4, this method will return an array of 4 float
+ * values that could look like [0, .33, .66, 1]. When interpolating through a list of
+ * [KeylineList]s, an interpolation value will be between 0-1. This interpolation will be
+ * used to find the range it falls within from this method's returned value. If
+ * interpolation is .25, that would fall between the 0 and .33, the 0th and 1st indices
+ * of the float array. Meaning the 0th and 1st items from [steps] should be the current
+ * [KeylineList]s being interpolated. This is an example with equally distributed values
+ * but these values will typically be unequally distributed since their size depends on
+ * the distance keylines shift between each step.
+ *
+ * @see [lerp] for more details on how interpolation points are used
+ * @see [Strategy.getKeylineListForScrollOffset] for more details on how interpolation points
+ * are used
+ *
+ * @param totalShiftDistance the total distance keylines will shift between the first and
+ * last [KeylineList] of [steps]
+ * @param steps the steps to find interpolation points for
+ * @param isShiftingLeft true if this method should find interpolation points for shifting
+ * keylines to the left/top of a carousel, false if this method should find interpolation
+ * points for shifting keylines to the right/bottom of a carousel
+ * @return a list of floats, equal in size to [steps] that contains points between 0-1
+ * that align with when a [KeylineList] from [steps should be shown for a 0-1
+ * interpolation value
+ */
+private fun getStepInterpolationPoints(
+    totalShiftDistance: Float,
+    steps: List<KeylineList>,
+    isShiftingLeft: Boolean
+): FloatList {
+    val points = mutableFloatListOf(0f)
+    if (totalShiftDistance == 0f || steps.isEmpty()) {
+        return points
+    }
+
+    (1 until steps.size).map { i ->
+        val prevKeylines = steps[i - 1]
+        val currKeylines = steps[i]
+        val distanceShifted = if (isShiftingLeft) {
+            currKeylines.first().unadjustedOffset - prevKeylines.first().unadjustedOffset
+        } else {
+            prevKeylines.last().unadjustedOffset - currKeylines.last().unadjustedOffset
+        }
+        val stepPercentage = distanceShifted / totalShiftDistance
+        val point = if (i == steps.lastIndex) 1f else points[i - 1] + stepPercentage
+        points.add(point)
+    }
+    return points
+}
+
+private data class ShiftPointRange(
+    val fromStepIndex: Int,
+    val toStepIndex: Int,
+    val steppedInterpolation: Float
+)
+
+private fun getShiftPointRange(
+    stepsCount: Int,
+    shiftPoint: FloatList,
+    interpolation: Float
+): ShiftPointRange {
+    var lowerBounds = shiftPoint[0]
+    (1 until stepsCount).forEach { i ->
+        val upperBounds = shiftPoint[i]
+        if (interpolation <= upperBounds) {
+            return ShiftPointRange(
+                fromStepIndex = i - 1,
+                toStepIndex = i,
+                steppedInterpolation = lerp(0f, 1f, lowerBounds, upperBounds, interpolation)
+            )
+        }
+        lowerBounds = upperBounds
+    }
+    return ShiftPointRange(
+        fromStepIndex = 0,
+        toStepIndex = 0,
+        steppedInterpolation = 0f
+    )
+}
+
+private fun MutableList<Keyline>.move(srcIndex: Int, dstIndex: Int): MutableList<Keyline> {
+    val keyline = get(srcIndex)
+    removeAt(srcIndex)
+    add(dstIndex, keyline)
+    return this
 }
 
 private fun lerp(