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(