diff options
author | Hunter Stich <hunterstich@google.com> | 2024-02-06 20:01:06 +0000 |
---|---|---|
committer | Gerrit Code Review <noreply-gerritcodereview@google.com> | 2024-02-06 20:01:06 +0000 |
commit | f3e658f3d732d5da8067cc4d03a21d34316529ff (patch) | |
tree | c8f9428eeebd249e38cbd6280d4bd896053c4e11 | |
parent | cc4cff9e2687ad5a2c41080b3e1bc32bdbb32d93 (diff) | |
parent | bbe5a9ece4f6f6ebff4fe18ee12347002b37ad97 (diff) | |
download | support-f3e658f3d732d5da8067cc4d03a21d34316529ff.tar.gz |
Merge "[Carousel] Update top-level component composables to be orientation and variant specific." into androidx-main
8 files changed, 427 insertions, 438 deletions
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 58c41be69b9..8868c742c23 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 @@ -22,6 +22,8 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicText import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.lightColorScheme @@ -30,11 +32,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeWithVelocity +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.google.common.truth.Truth.assertThat @@ -86,25 +90,14 @@ class CarouselTest { } } - @Test - fun carousel_testInitialItem() { - // Arrange - createCarousel(initialItem = 5, orientation = Orientation.Horizontal) - - // Assert - rule.runOnIdle { - assertThat(carouselState.pagerState.currentPage).isEqualTo(5) - } - } - @Composable internal fun Item(index: Int) { Box( modifier = Modifier - .fillMaxSize() - .background(Color.Blue) - .testTag("$index") - .focusable(), + .fillMaxSize() + .background(Color.Blue) + .testTag("$index") + .focusable(), contentAlignment = Alignment.Center ) { BasicText(text = index.toString()) @@ -114,30 +107,30 @@ class CarouselTest { private fun createCarousel( initialItem: Int = 0, itemCount: () -> Int = { DefaultItemCount }, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier.width(412.dp).height(221.dp), orientation: Orientation = Orientation.Horizontal, content: @Composable CarouselScope.(item: Int) -> Unit = { Item(index = it) } ) { rule.setMaterialContent(lightColorScheme()) { - val state = rememberCarouselState( - initialItem = initialItem, - itemCount = itemCount, - ).also { + val state = rememberCarouselState(initialItem, itemCount).also { carouselState = it } - if (orientation == Orientation.Horizontal) { - HorizontalCarousel( - state = state, - modifier = modifier.testTag(CarouselTestTag), - content = content, - ) - } else { - VerticalCarousel( - state = state, - modifier = modifier.testTag(CarouselTestTag), - content = content, - ) - } + val density = LocalDensity.current + Carousel( + state = state, + orientation = orientation, + keylineList = { multiBrowseKeylineList( + density, + state.pagerState.layoutInfo.viewportSize.let { + if (orientation == Orientation.Horizontal) it.width else it.height + }.toFloat(), + preferredItemSize = with(density) { 186.dp.toPx() }, + itemSpacing = 0f + ) }, + modifier = modifier.testTag(CarouselTestTag), + itemSpacing = 0.dp, + content = content, + ) } } } diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseStrategyTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseStrategyTest.kt deleted file mode 100644 index 8e0f286a03c..00000000000 --- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseStrategyTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.material3.carousel - -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 - -@RunWith(JUnit4::class) -class MultiBrowseStrategyTest { - - private val Density = Density(1f) - - @Test - fun testStrategy_doesntResizeLargeWhenEnoughRoom() { - - val itemSize = 120.dp // minSmallItemSize = 40.dp * 3 - val strategyProvider = MultiBrowseStrategyProvider(itemSize) - val strategy = strategyProvider.createStrategy( - density = Density, - carouselMainAxisSize = 500f, - itemSpacing = 0) - - assertThat(strategy?.itemMainAxisSize).isEqualTo(with(Density) { itemSize.toPx() }) - } - - @Test - fun testStrategy_resizesItemLargerThanContainerToFit1Small() { - val itemSize = 200f - val strategyProvider = MultiBrowseStrategyProvider(with(Density) { itemSize.toDp() }) - val strategy = strategyProvider.createStrategy( - density = Density, - carouselMainAxisSize = 100f, - itemSpacing = 0) - - val minSmallItemSize: Float = with(Density) { StrategyDefaults.minSmallSize.toPx() } - val keylines = strategy?.getDefaultKeylines() - - // If the item size given is larger than the container, the adjusted keyline list from - // the MultibrowseStrategy should be [xSmall-Large-Small-xSmall] - assertThat(strategy?.itemMainAxisSize).isAtMost(100f) - assertThat(keylines).hasSize(4) - assertThat(keylines?.get(0)?.unadjustedOffset).isLessThan(0f) - assertThat(keylines?.get(keylines.lastIndex)?.unadjustedOffset).isGreaterThan(100f) - assertThat(keylines?.get(1)?.isFocal).isTrue() - assertThat(keylines?.get(2)?.size).isEqualTo(minSmallItemSize) - } - - @Test - fun testStrategy_hasNoSmallItemsIfNotEnoughRoom() { - val minSmallItemSize: Float = with(Density) { StrategyDefaults.minSmallSize.toPx() } - val strategyProvider = MultiBrowseStrategyProvider(with(Density) { 200f.toDp() }) - val strategy = strategyProvider.createStrategy( - density = Density, - carouselMainAxisSize = minSmallItemSize, - itemSpacing = 0) - val keylines = strategy?.getDefaultKeylines() - - assertThat(strategy?.itemMainAxisSize).isEqualTo(minSmallItemSize) - assertThat(keylines?.firstFocal == keylines?.firstNonAnchor) - assertThat(keylines?.lastFocal == keylines?.lastNonAnchor) - } - - @Test - fun testStrategy_isNullIfAvailableSpaceIsZero() { - val strategyProvider = MultiBrowseStrategyProvider(with(Density) { 200f.toDp() }) - val strategy = strategyProvider.createStrategy( - density = Density, - carouselMainAxisSize = 0F, - itemSpacing = 0) - - assertThat(strategy).isNull() - } - - @Test - fun testStrategy_adjustsMediumSizeToBeProportional() { - val maxSmallItemSize: Float = with(Density) { StrategyDefaults.maxSmallSize.toPx() } - val targetItemSize = 200f - val carouselSize = targetItemSize * 2 + maxSmallItemSize * 2 - val strategyProvider = MultiBrowseStrategyProvider(with(Density) { targetItemSize.toDp() }) - val strategy = strategyProvider.createStrategy( - density = Density, - carouselMainAxisSize = carouselSize, - itemSpacing = 0) - val keylines = strategy?.getDefaultKeylines() - - // Assert that there's only one small item, and a medium item that has a size between - // the large and small items - // We expect a keyline list of [xSmall-Large-Large-Medium-Small-xSmall] - assertThat(keylines).hasSize(6) - assertThat(keylines?.get(1)?.isFocal).isTrue() - assertThat(keylines?.get(2)?.isFocal).isTrue() - assertThat(keylines?.get(3)?.size).isLessThan(keylines?.get(2)?.size) - assertThat(keylines?.get(4)?.size).isLessThan(keylines?.get(3)?.size) - } -} 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 new file mode 100644 index 00000000000..2b6d054dec3 --- /dev/null +++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.carousel + +import androidx.compose.ui.unit.Density +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class MultiBrowseTest { + + private val density = Density(1f) + + @Test + fun testMultiBrowse_doesNotResizeLargeWhenEnoughRoom() { + val itemSize = 120f // minSmallItemSize = 40.dp * 3 + val keylineList = multiBrowseKeylineList( + density = density, + carouselMainAxisSize = 500f, + preferredItemSize = itemSize, + itemSpacing = 0f + )!! + val strategy = Strategy.create( + carouselMainAxisSize = 500f, + keylineList = keylineList + ) + + assertThat(strategy.itemMainAxisSize).isEqualTo(itemSize) + } + + @Test + fun testMultiBrowse_resizesItemLargerThanContainerToFit1Small() { + val itemSize = 200f + val keylineList = multiBrowseKeylineList( + density = density, + carouselMainAxisSize = 100f, + preferredItemSize = itemSize, + itemSpacing = 0f + )!! + + val strategy = Strategy.create( + carouselMainAxisSize = 100f, + keylineList = keylineList + ) + val minSmallItemSize: Float = with(density) { StrategyDefaults.minSmallSize.toPx() } + val keylines = strategy.getDefaultKeylines() + + // If the item size given is larger than the container, the adjusted keyline list from + // the multi-browse keyline list should be [xSmall-Large-Small-xSmall] + assertThat(strategy.itemMainAxisSize).isAtMost(100f) + assertThat(keylines).hasSize(4) + assertThat(keylines[0].unadjustedOffset).isLessThan(0f) + assertThat(keylines[keylines.lastIndex].unadjustedOffset).isGreaterThan(100f) + assertThat(keylines[1].isFocal).isTrue() + assertThat(keylines[2].size).isEqualTo(minSmallItemSize) + } + + @Test + fun testMultiBrowse_hasNoSmallItemsIfNotEnoughRoom() { + val minSmallItemSize: Float = with(density) { StrategyDefaults.minSmallSize.toPx() } + val keylineList = multiBrowseKeylineList( + density = density, + carouselMainAxisSize = minSmallItemSize, + preferredItemSize = 200f, + itemSpacing = 0f + )!! + val strategy = Strategy.create( + carouselMainAxisSize = minSmallItemSize, + keylineList = keylineList + ) + val keylines = strategy.getDefaultKeylines() + + assertThat(strategy.itemMainAxisSize).isEqualTo(minSmallItemSize) + assertThat(keylines.firstFocal == keylines.firstNonAnchor) + assertThat(keylines.lastFocal == keylines.lastNonAnchor) + } + + @Test + fun testMultiBrowse_isNullIfAvailableSpaceIsZero() { + val keylineList = multiBrowseKeylineList( + density = density, + carouselMainAxisSize = 0f, + preferredItemSize = 200f, + itemSpacing = 0f + ) + assertThat(keylineList).isNull() + } + + @Test + fun testMultiBrowse_adjustsMediumSizeToBeProportional() { + val maxSmallItemSize: Float = with(density) { StrategyDefaults.maxSmallSize.toPx() } + val preferredItemSize = 200f + val carouselSize = preferredItemSize * 2 + maxSmallItemSize * 2 + val keylineList = multiBrowseKeylineList( + density = density, + carouselMainAxisSize = carouselSize, + preferredItemSize = preferredItemSize, + itemSpacing = 0f + )!! + val strategy = Strategy.create( + carouselMainAxisSize = carouselSize, + keylineList = keylineList + ) + val keylines = strategy.getDefaultKeylines() + + // Assert that there's only one small item, and a medium item that has a size between + // the large and small items + // We expect a keyline list of [xSmall-Large-Large-Medium-Small-xSmall] + assertThat(keylines).hasSize(6) + assertThat(keylines[1].isFocal).isTrue() + assertThat(keylines[2].isFocal).isTrue() + assertThat(keylines[3].size).isLessThan(keylines[2].size) + assertThat(keylines[4].size).isLessThan(keylines[3].size) + } +} 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 f27a6b6b282..8982afe8afd 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,6 +17,8 @@ package androidx.compose.material3.carousel import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.VerticalPager @@ -33,31 +35,166 @@ 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.unit.Density -import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import kotlin.math.roundToInt /** - * A enumeration of ways items can be aligned along a carousel's main axis + * <a href=https://m3.material.io/components/carousel/overview" class="external" target="_blank">Material Design Carousel</a> + * + * A horizontal carousel meant to display many items at once for quick browsing of smaller content + * like album art or photo thumbnails. + * + * Note that this carousel may adjust the size of large items. In order to ensure a mix of large, + * medium, and small items fit perfectly into the available space and are arranged in a + * visually pleasing way, this carousel finds the nearest number of large items that + * will fit the container and adjusts their size to fit, if necessary. + * + * For more information, see <a href="https://material.io/components/carousel/overview">design + * guidelines</a>. + * + * @param state The state object to be used to control the carousel's state. + * @param preferredItemSize The size fully visible items would like to be in the main axis. This + * size is a target and will likely be adjusted by carousel in order to fit a whole number of + * items within the container. + * @param modifier A modifier instance to be applied to this carousel outer layout + * @param itemSpacing The amount of space used to separate items in the carousel + * @param minSmallSize The minimum allowable size of small masked items + * @param maxSmallSize The maximum allowable size of small masked items + * @param content The carousel's content Composable. + * + * TODO: Add sample link */ -internal enum class CarouselAlignment { - /** Start aligned carousels place focal items at the start/top of the container */ - Start, - /** Center aligned carousels place focal items in the middle of the container */ - Center, - /** End aligned carousels place focal items at the end/bottom of the container */ - End +@Suppress("IllegalExperimentalApiUsage") +@OptIn(ExperimentalFoundationApi::class) +@ExperimentalMaterial3Api +@Composable +internal fun HorizontalMultiBrowseCarousel( + state: CarouselState, + preferredItemSize: Dp, + modifier: Modifier = Modifier, + itemSpacing: Dp = 0.dp, + minSmallSize: Dp = StrategyDefaults.minSmallSize, + maxSmallSize: Dp = StrategyDefaults.maxSmallSize, + content: @Composable CarouselScope.(item: Int) -> Unit +) { + val density = LocalDensity.current + Carousel( + state = state, + orientation = Orientation.Horizontal, + keylineList = { + with(density) { + multiBrowseKeylineList( + density = this, + carouselMainAxisSize = state.pagerState.layoutInfo.viewportSize.width.toFloat(), + preferredItemSize = preferredItemSize.toPx(), + itemSpacing = itemSpacing.toPx(), + minSmallSize = minSmallSize.toPx(), + maxSmallSize = maxSmallSize.toPx() + ) + } + }, + modifier = modifier, + itemSpacing = itemSpacing, + content = content + ) } /** - * An enumeration of orientations that determine a carousel's main axis + * <a href=https://m3.material.io/components/carousel/overview" class="external" target="_blank">Material Design Carousel</a> + * + * Carousels contain a collection of items that changes sizes according to their placement and the + * chosen strategy. + * + * @param state The state object to be used to control the carousel's state. + * @param modifier A modifier instance to be applied to this carousel outer layout + * @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 itemSpacing The amount of space used to separate items in the carousel + * @param orientation The layout orientation of the carousel + * @param content The carousel's content Composable where each call is passed the index, from the + * total item count, of the item being composed + * TODO: Add sample link */ -internal enum class Orientation { - /** Vertical orientation representing Y axis */ - Vertical, - /** Horizontal orientation representing X axis */ - Horizontal +// TODO: b/321997456 - Remove lint suppression once version checks are added in lint or library +// moves to beta +@Suppress("IllegalExperimentalApiUsage") +@OptIn(ExperimentalFoundationApi::class) +@ExperimentalMaterial3Api +@Composable +internal fun Carousel( + state: CarouselState, + orientation: Orientation, + keylineList: () -> KeylineList?, + modifier: Modifier = Modifier, + itemSpacing: Dp = 0.dp, + content: @Composable CarouselScope.(itemIndex: Int) -> Unit +) { + val availableSpace = if (orientation == Orientation.Horizontal) { + state.pagerState.layoutInfo.viewportSize.width.toFloat() + } else { + state.pagerState.layoutInfo.viewportSize.height.toFloat() + } + val keylines: KeylineList? = keylineList.invoke() + val strategy = if (keylines != null) { + Strategy.create(availableSpace, keylines) + } else { + null + } + + val pageSize = PageSize.Fixed( + with(LocalDensity.current) { strategy?.itemMainAxisSize?.toDp() } ?: 0.dp + ) + + // TODO: Update beyond bounds numbers according to Strategy + val outOfBoundsPageCount = 2 + val carouselScope = CarouselScopeImpl + + if (orientation == Orientation.Horizontal) { + HorizontalPager( + state = state.pagerState, + pageSize = pageSize, + pageSpacing = itemSpacing, + outOfBoundsPageCount = outOfBoundsPageCount, + modifier = modifier + ) { page -> + Box(modifier = Modifier.carouselItem(page, state, strategy)) { + carouselScope.content(page) + } + } + } else if (orientation == Orientation.Vertical) { + VerticalPager( + state = state.pagerState, + pageSize = pageSize, + pageSpacing = itemSpacing, + outOfBoundsPageCount = outOfBoundsPageCount, + modifier = modifier + ) { page -> + Box(modifier = Modifier.carouselItem(page, state, strategy)) { + carouselScope.content(page) + } + } + } +} + +/** + * This class defines ways items can be aligned along a carousel's main axis. + */ +@JvmInline +internal value class CarouselAlignment private constructor(internal val value: Int) { + companion object { + /** Start aligned carousels place focal items at the start/top of the container */ + val Start = CarouselAlignment(-1) + + /** Center aligned carousels place focal items in the middle of the container */ + val Center = CarouselAlignment(0) + + /** End aligned carousels place focal items at the end/bottom of the container */ + val End = CarouselAlignment(1) + } } /** @@ -65,25 +202,31 @@ internal enum class Orientation { * of a Carousel. * * @param index the index of the item in the carousel - * @param viewportSize the size of the carousel container - * @param orientation the orientation of the carousel - * @param itemsCount the total number of items in the carousel - * @param scrollOffset the amount the carousel has been scrolled in pixels + * @param state the carousel state * @param strategy the strategy used to mask and translate items in the carousel */ +@Suppress("IllegalExperimentalApiUsage") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) internal fun Modifier.carouselItem( index: Int, - itemsCount: Int, - viewportSize: IntSize, - orientation: Orientation, - scrollOffset: Float, - strategy: Strategy + state: CarouselState, + strategy: Strategy? ): Modifier { + val viewportSize = state.pagerState.layoutInfo.viewportSize + val orientation = state.pagerState.layoutInfo.orientation val isVertical = orientation == Orientation.Vertical val mainAxisCarouselSize = if (isVertical) viewportSize.height else viewportSize.width - if (mainAxisCarouselSize == 0) { + + if (mainAxisCarouselSize == 0 || strategy == null) { return this } + // Scroll offset calculation using currentPage and currentPageOffsetFraction + val firstVisibleItemScrollOffset = + state.pagerState.currentPageOffsetFraction * strategy.itemMainAxisSize + val scrollOffset = (state.pagerState.currentPage * strategy.itemMainAxisSize) + + firstVisibleItemScrollOffset + val itemsCount = state.pagerState.pageCount + val maxScrollOffset = itemsCount * strategy.itemMainAxisSize - mainAxisCarouselSize val keylines = strategy.getKeylineListForScrollOffset(scrollOffset, maxScrollOffset) @@ -203,101 +346,3 @@ private fun getProgress(before: Keyline, after: Keyline, unadjustedOffset: Float val total = after.unadjustedOffset - before.unadjustedOffset return (unadjustedOffset - before.unadjustedOffset) / total } - -/** - * <a href=https://m3.material.io/components/carousel/overview" class="external" target="_blank">Material Design Carousel</a> - * - * A Carousel that scrolls horizontally. Carousels contain a collection of items that changes sizes - * according to their placement and the chosen strategy. - * - * @param state The state object to be used to control the carousel's state. - * @param modifier A modifier instance to be applied to this carousel outer layout - * @param content The carousel's content Composable. - * TODO: Add sample link - */ -@ExperimentalMaterial3Api -@Composable -internal fun HorizontalCarousel( - state: CarouselState, - modifier: Modifier = Modifier, - content: @Composable CarouselScope.(item: Int) -> Unit -) = Carousel( - state = state, - modifier = modifier, - orientation = Orientation.Horizontal, - content = content -) - -/** - * <a href=https://m3.material.io/components/carousel/overview" class="external" target="_blank">Material Design Carousel</a> - * - * A Carousel that scrolls vertically. Carousels contain a collection of items that changes sizes - * according to their placement and the chosen strategy. - * - * @param state The state object to be used to control the carousel's state. - * @param modifier A modifier instance to be applied to this carousel outer layout - * @param content The carousel's content Composable. - * TODO: Add sample link - */ -@ExperimentalMaterial3Api -@Composable -internal fun VerticalCarousel( - state: CarouselState, - modifier: Modifier = Modifier, - content: @Composable CarouselScope.(item: Int) -> Unit -) = Carousel( - state = state, - modifier = modifier, - orientation = Orientation.Vertical, - content = content -) - -/** - * <a href=https://m3.material.io/components/carousel/overview" class="external" target="_blank">Material Design Carousel</a> - * - * Carousels contain a collection of items that changes sizes according to their placement and the - * chosen strategy. - * - * @param state The state object to be used to control the carousel's state. - * @param modifier A modifier instance to be applied to this carousel outer layout - * @param orientation The layout orientation of the carousel - * @param content The carousel's content Composable. - * TODO: Add sample link - */ -// TODO: b/321997456 - Remove lint suppression once version checks are added in lint or library -// moves to beta -@Suppress("IllegalExperimentalApiUsage") -@OptIn(ExperimentalFoundationApi::class) -@ExperimentalMaterial3Api -@Composable -internal fun Carousel( - state: CarouselState, - modifier: Modifier = Modifier, - orientation: Orientation = Orientation.Horizontal, - content: @Composable CarouselScope.(item: Int) -> Unit -) { - // TODO: Update page size according to strategy - val pageSize = PageSize.Fill - // TODO: Update out of bounds page count numbers - val outOfBoundsPageCount = 1 - val carouselScope = CarouselScopeImpl - if (orientation == Orientation.Horizontal) { - HorizontalPager( - state = state.pagerState, - pageSize = pageSize, - outOfBoundsPageCount = outOfBoundsPageCount, - modifier = modifier - ) { page -> - carouselScope.content(page) - } - } else if (orientation == Orientation.Vertical) { - VerticalPager( - state = state.pagerState, - pageSize = pageSize, - outOfBoundsPageCount = outOfBoundsPageCount, - modifier = modifier - ) { page -> - carouselScope.content(page) - } - } -} 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 e48ec30f04d..65b7ac4f665 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 @@ -29,7 +29,7 @@ import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable /** - * The state that can be used to control [VerticalCarousel] and [HorizontalCarousel]. + * The state that can be used to control all types of carousels. * * @param currentItem the current item to be scrolled to. * @param currentItemOffsetFraction the current item offset as a fraction of the item size. diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keyline.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keyline.kt index eeda9e6375c..c4451375005 100644 --- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keyline.kt +++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keyline.kt @@ -16,9 +16,101 @@ package androidx.compose.material3.carousel +import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastMapIndexed import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min + +/** + * Creates a list of keylines that arranges items into a multi-browse configuration. + * + * Note that this function may adjust the size of large items. In order to ensure large, medium, + * and small items fit perfectly into the available space and are numbered/arranged in a + * visually pleasing and opinionated way, this strategy finds the nearest number of large items that + * will fit into an approved arrangement that requires the least amount of size adjustment + * necessary. + * + * For more information, see <a href="https://material.io/components/carousel/overview">design + * guidelines</a>. + * + * @param density The [Density] object that provides pixel density information of the device + * @param carouselMainAxisSize the size of the carousel container, in pixels, in the main + * scrolling axis + * @param preferredItemSize the desired size of large items, in pixels, in the main scrolling axis + * @param itemSpacing the spacing between items in pixels + * @param minSmallSize the minimum allowable size of small items in pixels + * @param maxSmallSize the maximum allowable size of small items in pixels + */ +internal fun multiBrowseKeylineList( + density: Density, + carouselMainAxisSize: Float, + preferredItemSize: Float, + itemSpacing: Float, + minSmallSize: Float = with(density) { StrategyDefaults.minSmallSize.toPx() }, + maxSmallSize: Float = with(density) { StrategyDefaults.maxSmallSize.toPx() }, +): KeylineList? { + if (carouselMainAxisSize == 0f || preferredItemSize == 0f) { + return null + } + + var smallCounts: IntArray = intArrayOf(1) + val mediumCounts: IntArray = intArrayOf(1, 0) + + val targetLargeSize: Float = min(preferredItemSize + itemSpacing, carouselMainAxisSize) + // Ideally we would like to create a balanced arrangement where a small item is 1/3 the size + // of the large item and medium items are sized between large and small items. Clamp the + // small target size within our min-max range and as close to 1/3 of the target large item + // size as possible. + val targetSmallSize: Float = (targetLargeSize / 3f + itemSpacing).coerceIn( + minSmallSize + itemSpacing, + maxSmallSize + itemSpacing + ) + val targetMediumSize = (targetLargeSize + targetSmallSize) / 2f + + if (carouselMainAxisSize < minSmallSize * 2) { + // If the available space is too small to fit a large item and small item (where a large + // item is bigger than a small item), allow arrangements with + // no small items. + smallCounts = intArrayOf(0) + } + + // Find the minimum space left for large items after filling the carousel with the most + // permissible medium and small items to determine a plausible minimum large count. + val minAvailableLargeSpace = carouselMainAxisSize - targetMediumSize * mediumCounts.max() - + maxSmallSize * smallCounts.max() + val minLargeCount = max( + 1, + floor(minAvailableLargeSpace / targetLargeSize).toInt()) + val maxLargeCount = ceil(carouselMainAxisSize / targetLargeSize).toInt() + + val largeCounts = IntArray(maxLargeCount - minLargeCount + 1) { maxLargeCount - it } + val anchorSize = with(density) { StrategyDefaults.anchorSize.toPx() } + val arrangement = Arrangement.findLowestCostArrangement( + availableSpace = carouselMainAxisSize, + targetSmallSize = targetSmallSize, + minSmallSize = minSmallSize, + maxSmallSize = maxSmallSize, + smallCounts = smallCounts, + targetMediumSize = targetMediumSize, + mediumCounts = mediumCounts, + targetLargeSize = targetLargeSize, + largeCounts = largeCounts, + ) ?: return null + + return keylineListOf(carouselMainAxisSize, CarouselAlignment.Start) { + add(anchorSize, isAnchor = true) + + repeat(arrangement.largeCount) { add(arrangement.largeSize) } + repeat(arrangement.mediumCount) { add(arrangement.mediumSize) } + repeat(arrangement.smallCount) { add(arrangement.smallSize) } + + add(anchorSize, isAnchor = true) + } +} /** * A structure that is fixed at a specific [offset] along a scrolling axis and @@ -294,11 +386,12 @@ private class KeylineListScopeImpl : KeylineListScope { pivotIndex = firstFocalIndex pivotOffset = when (carouselAlignment) { - CarouselAlignment.Start -> focalItemSize / 2 CarouselAlignment.Center -> { (carouselMainAxisSize / 2) - ((focalItemSize / 2) * focalItemCount) } CarouselAlignment.End -> carouselMainAxisSize - (focalItemSize / 2) + // Else covers and defaults to CarouselAlignment.Start + else -> focalItemSize / 2 } val keylines = createKeylinesWithPivot( diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/MultiBrowseStrategyProvider.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/MultiBrowseStrategyProvider.kt deleted file mode 100644 index f8d4db6626e..00000000000 --- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/MultiBrowseStrategyProvider.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.material3.carousel - -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlin.math.ceil -import kotlin.math.floor -import kotlin.math.max -import kotlin.math.min - -/** - * A [StrategyProvider] that provides the multi-browse strategy, which fits large, medium, and small - * items into a layout for quick browsing of multiple items at once. - * - * Note that this strategy may adjust the size of large items. In order to ensure large, medium, - * and small items fit perfectly into the available space and are numbered/arranged in a - * visually pleasing and opinionated way, this strategy finds the nearest number of large items that - * will fit into an approved arrangement that requires the least amount of size adjustment - * necessary. - * - * For more information, see <a href="https://material.io/components/carousel/overview">design - * guidelines</a>. - */ -internal class MultiBrowseStrategyProvider( - private val targetLargeItemMainAxisSize: Dp, - private val minSmallSize: Dp = StrategyDefaults.minSmallSize, - private val maxSmallSize: Dp = StrategyDefaults.maxSmallSize -) : - StrategyProvider() { - - override fun createStrategy( - density: Density, - carouselMainAxisSize: Float, - itemSpacing: Int, - ): Strategy? { - if (carouselMainAxisSize == 0f || targetLargeItemMainAxisSize == 0.dp) { - return null - } - - val targetLargeItemSize = with(density) { targetLargeItemMainAxisSize.toPx() } - val minSmallItemSize = with(density) { minSmallSize.toPx() } - val maxSmallItemSize = with(density) { maxSmallSize.toPx() } - var smallCounts: IntArray = intArrayOf(1) - val mediumCounts: IntArray = intArrayOf(1, 0) - - val targetLargeSize: Float = min(targetLargeItemSize + itemSpacing, carouselMainAxisSize) - // Ideally we would like to create a balanced arrangement where a small item is 1/3 the size - // of the large item and medium items are sized between large and small items. Clamp the - // small target size within our min-max range and as close to 1/3 of the target large item - // size as possible. - val targetSmallSize: Float = (targetLargeItemSize / 3f + itemSpacing).coerceIn( - minSmallItemSize + itemSpacing, - maxSmallItemSize + itemSpacing - ) - val targetMediumSize = (targetLargeSize + targetSmallSize) / 2f - - if (carouselMainAxisSize < minSmallItemSize * 2) { - // If the available space is too small to fit a large item and small item (where a large - // item is bigger than a small item), allow arrangements with - // no small items. - smallCounts = intArrayOf(0) - } - - // Find the minimum space left for large items after filling the carousel with the most - // permissible medium and small items to determine a plausible minimum large count. - val minAvailableLargeSpace = carouselMainAxisSize - targetMediumSize * mediumCounts.max() - - maxSmallItemSize * smallCounts.max() - val minLargeCount = max( - 1, - floor(minAvailableLargeSpace / targetLargeSize).toInt()) - val maxLargeCount = ceil(carouselMainAxisSize / targetLargeSize).toInt() - - val largeCounts = IntArray(maxLargeCount - minLargeCount + 1) { maxLargeCount - it } - val arrangement = Arrangement.findLowestCostArrangement( - availableSpace = carouselMainAxisSize, - targetSmallSize = targetSmallSize, - minSmallSize = minSmallItemSize, - maxSmallSize = maxSmallItemSize, - smallCounts = smallCounts, - targetMediumSize = targetMediumSize, - mediumCounts = mediumCounts, - targetLargeSize = targetLargeSize, - largeCounts = largeCounts, - ) ?: return null - - return createStartAlignedStrategy( - availableSpace = carouselMainAxisSize, - arrangement = arrangement, - anchorSize = with(density) { StrategyDefaults.anchorSize.toPx() } - ) - } -} 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 484c6ad0816..8ca58e4bb6f 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 @@ -19,7 +19,6 @@ package androidx.compose.material3.carousel import androidx.annotation.VisibleForTesting import androidx.collection.FloatList import androidx.collection.mutableFloatListOf -import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.lerp @@ -35,68 +34,17 @@ internal object StrategyDefaults { } /** - * Helper method to create a default start-aligned [Strategy] contained within the bounds of - * [availableSpace] and based on the given [Arrangement]. - * - * @param availableSpace the available space to contain the [Strategy] within - * @param arrangement the arrangement containing information on the sizes and counts of the - * items in the [Strategy]. - * @param anchorSize the size that the anchor keylines should be in the strategy. The smaller - * this is, the more the item will shrink as it moves off-screen. - */ -internal fun createStartAlignedStrategy( - availableSpace: Float, - arrangement: Arrangement, - anchorSize: Float, -): Strategy { - val keylineList = keylineListOf(availableSpace, CarouselAlignment.Start) { - add(anchorSize, isAnchor = true) - - repeat(arrangement.largeCount) { add(arrangement.largeSize) } - repeat(arrangement.mediumCount) { add(arrangement.mediumSize) } - repeat(arrangement.smallCount) { add(arrangement.smallSize) } - - add(anchorSize, isAnchor = true) - } - - return Strategy.create(availableSpace, keylineList) -} - -/** - * A class that provides [Strategy] instances to a scrollable component. - * - * [StrategyProvider.createStrategy] will be called any time properties which affect a carousel's - * arrangement change. It is the implementation's responsibility to create an arrangement for the - * given parameters and return a [Strategy] by calling [Strategy.create]. - */ -internal sealed class StrategyProvider() { - - /** - * Create and return a new [Strategy] for the given carousel size. - * - * @param density The current density value - * @param carouselMainAxisSize the size of the carousel in the main axis in pixels - * @param itemSpacing The spacing in between the items that are not a part of the item size - */ - internal abstract fun createStrategy( - density: Density, - carouselMainAxisSize: Float, - itemSpacing: Int, - ): Strategy? -} - -/** * A class which supplies carousel with the appropriate [KeylineList] for any given scroll offset. * * All items in a carousel need the opportunity to pass through the focal keyline range. Depending * on where the focal range is located within the scrolling container, some items, like those at * the beginning or end of the list, might not reach the focal range. To account for this, * [Strategy] manages shifting the focal keylines to the beginning of the list when scrolled an - * offset of 0 and the end of the list when scrolled to the list's max offset. [StrategyProvider] - * needs only to create a "default" [KeylineList] (where keylines should be placed when scrolling - * in the middle of the list) and call [Strategy.create] to have [Strategy] generate the steps - * needed to move the focal range to the start and end of the scroll container. When scrolling, the - * scrollable component can access the correct [KeylineList] for any given offset using + * offset of 0 and the end of the list when scrolled to the list's max offset. The only thing + * [Strategy] needs is a "default" [KeylineList] (where keylines should be placed when scrolling + * in the middle of the list) and [Strategy.create] will generate the steps needed to move the + * focal range to the start and end of the scroll container. When scrolling, the scrollable + * component can access the correct [KeylineList] for any given offset using * [getKeylineListForScrollOffset]. * * @param defaultKeylines the [KeylineList] used when anywhere in the center of the list @@ -211,7 +159,6 @@ internal class Strategy private constructor( /** * Creates a new [Strategy] based on a default [keylineList]. - * * The [keylineList] passed to this method will be the keylines used when the carousel is * scrolled anywhere in the middle of the list (not the beginning or end). From these * default keylines, additional [KeylineList]s will be created which move the focal range @@ -501,7 +448,8 @@ internal class Strategy private constructor( return ShiftPointRange( fromStepIndex = 0, toStepIndex = 0, - steppedInterpolation = 0f) + steppedInterpolation = 0f + ) } private fun MutableList<Keyline>.move(srcIndex: Int, dstIndex: Int): MutableList<Keyline> { |