diff --git a/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java b/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java index 617e08cfb4c..25bddd5423c 100644 --- a/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java +++ b/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java @@ -114,7 +114,11 @@ private static final class ChildCalculations { } public CarouselLayoutManager() { - setCarouselStrategy(new MultiBrowseCarouselStrategy()); + this(new MultiBrowseCarouselStrategy()); + } + + public CarouselLayoutManager(@NonNull CarouselStrategy strategy) { + setCarouselStrategy(strategy); } @Override diff --git a/lib/java/com/google/android/material/carousel/HeroCarouselStrategy.java b/lib/java/com/google/android/material/carousel/HeroCarouselStrategy.java new file mode 100644 index 00000000000..eadd6e1b245 --- /dev/null +++ b/lib/java/com/google/android/material/carousel/HeroCarouselStrategy.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023 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 + * + * https://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 com.google.android.material.carousel; + +import static com.google.android.material.carousel.CarouselStrategyHelper.createLeftAlignedKeylineState; +import static com.google.android.material.carousel.CarouselStrategyHelper.getSmallSizeMax; +import static com.google.android.material.carousel.CarouselStrategyHelper.getSmallSizeMin; +import static com.google.android.material.carousel.CarouselStrategyHelper.maxValue; +import static java.lang.Math.ceil; +import static java.lang.Math.floor; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import androidx.recyclerview.widget.RecyclerView.LayoutParams; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.core.math.MathUtils; + +/** + * A {@link CarouselStrategy} that knows how to size and fit one large item and one small item into + * a container to create a layout to browse one 'hero' item at a time with a preview item. + * + *

Note that this strategy resizes Carousel items to take up the full width of the Carousel, save + * room for the small item. + * + *

This class will automatically be reversed by {@link CarouselLayoutManager} if being laid out + * right-to-left and does not need to make any account for layout direction itself. + */ +public class HeroCarouselStrategy extends CarouselStrategy { + + private static final int[] SMALL_COUNTS = new int[] {1}; + private static final int[] MEDIUM_COUNTS = new int[] {0}; + + @Override + @NonNull + KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNull View child) { + float availableSpace = carousel.getContainerWidth(); + + LayoutParams childLayoutParams = (LayoutParams) child.getLayoutParams(); + float childHorizontalMargins = childLayoutParams.leftMargin + childLayoutParams.rightMargin; + + float smallChildWidthMin = getSmallSizeMin(child.getContext()) + childHorizontalMargins; + float smallChildWidthMax = getSmallSizeMax(child.getContext()) + childHorizontalMargins; + + float measuredChildWidth = availableSpace; + float targetLargeChildWidth = min(measuredChildWidth + childHorizontalMargins, availableSpace); + // Ideally we would like to create a balanced arrangement where a small item is 1/3 the size of + // the large item. Clamp the small target size within our min-max range and as close to 1/3 of + // the target large item size as possible. + float targetSmallChildWidth = + MathUtils.clamp( + measuredChildWidth / 3F + childHorizontalMargins, + getSmallSizeMin(child.getContext()) + childHorizontalMargins, + getSmallSizeMax(child.getContext()) + childHorizontalMargins); + float targetMediumChildWidth = (targetLargeChildWidth + targetSmallChildWidth) / 2F; + + // Find the minimum space left for large items after filling the carousel with the most + // permissible small items to determine a plausible minimum large count. + float minAvailableLargeSpace = + availableSpace + - (smallChildWidthMax * maxValue(SMALL_COUNTS)); + int largeCountMin = (int) max(1, floor(minAvailableLargeSpace / targetLargeChildWidth)); + int largeCountMax = (int) ceil(availableSpace / targetLargeChildWidth); + int[] largeCounts = new int[largeCountMax - largeCountMin + 1]; + for (int i = 0; i < largeCounts.length; i++) { + largeCounts[i] = largeCountMin + i; + } + Arrangement arrangement = Arrangement.findLowestCostArrangement( + availableSpace, + targetSmallChildWidth, + smallChildWidthMin, + smallChildWidthMax, + SMALL_COUNTS, + targetMediumChildWidth, + MEDIUM_COUNTS, + targetLargeChildWidth, + largeCounts); + return createLeftAlignedKeylineState( + child.getContext(), childHorizontalMargins, availableSpace, arrangement); + } +} diff --git a/lib/javatests/com/google/android/material/carousel/CarouselHelper.java b/lib/javatests/com/google/android/material/carousel/CarouselHelper.java index 9af210cbc79..7cfb3654351 100644 --- a/lib/javatests/com/google/android/material/carousel/CarouselHelper.java +++ b/lib/javatests/com/google/android/material/carousel/CarouselHelper.java @@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertWithMessage; import static java.util.concurrent.TimeUnit.SECONDS; +import android.content.Context; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import androidx.recyclerview.widget.RecyclerView; @@ -306,4 +307,13 @@ static KeylineState getTestCenteredKeylineState() { .addKeyline(1315F, extraSmallMask, extraSmallSize) .build(); } + + static View createViewWithSize(Context context, int width, int height) { + View view = new View(context); + view.setLayoutParams(new RecyclerView.LayoutParams(width, height)); + view.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + return view; + } } diff --git a/lib/javatests/com/google/android/material/carousel/HeroCarouselStrategyTest.java b/lib/javatests/com/google/android/material/carousel/HeroCarouselStrategyTest.java new file mode 100644 index 00000000000..c163ca31304 --- /dev/null +++ b/lib/javatests/com/google/android/material/carousel/HeroCarouselStrategyTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2023 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 com.google.android.material.carousel; + +import com.google.android.material.test.R; + +import static com.google.android.material.carousel.CarouselHelper.createCarouselWithWidth; +import static com.google.android.material.carousel.CarouselHelper.createViewWithSize; +import static com.google.common.truth.Truth.assertThat; + +import androidx.recyclerview.widget.RecyclerView.LayoutParams; +import android.view.View; +import androidx.test.core.app.ApplicationProvider; +import com.google.android.material.carousel.KeylineState.Keyline; +import com.google.common.collect.Iterables; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link HeroCarouselStrategy}. */ +@RunWith(RobolectricTestRunner.class) +public class HeroCarouselStrategyTest { + + @Test + public void testItemSameAsContainerSize_showsOneLargeOneSmall() { + Carousel carousel = createCarouselWithWidth(400); + HeroCarouselStrategy config = new HeroCarouselStrategy(); + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 400, 400); + + KeylineState keylineState = config.onFirstChildMeasuredWithMargins(carousel, view); + float minSmallItemSize = + view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_min); + + // A fullscreen layout should be [xSmall-large-small-xSmall] where the xSmall items are + // outside the bounds of the carousel container and the large center item takes up the + // containers full width. + assertThat(keylineState.getKeylines()).hasSize(4); + assertThat(keylineState.getKeylines().get(0).locOffset).isLessThan(0F); + assertThat(Iterables.getLast(keylineState.getKeylines()).locOffset) + .isGreaterThan((float) carousel.getContainerWidth()); + assertThat(keylineState.getKeylines().get(1).mask).isEqualTo(0F); + assertThat(keylineState.getKeylines().get(2).maskedItemSize).isEqualTo(minSmallItemSize); + } + + @Test + public void testItemSmallerThanContainer_showsOneLargeOneSmall() { + Carousel carousel = createCarouselWithWidth(400); + HeroCarouselStrategy config = new HeroCarouselStrategy(); + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 100, 400); + + KeylineState keylineState = config.onFirstChildMeasuredWithMargins(carousel, view); + float minSmallItemSize = + view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_min); + + // A fullscreen layout should be [xSmall-large-small-xSmall] where the xSmall items are + // outside the bounds of the carousel container and the large center item takes up the + // containers full width. + assertThat(keylineState.getKeylines()).hasSize(4); + assertThat(keylineState.getKeylines().get(0).locOffset).isLessThan(0F); + assertThat(Iterables.getLast(keylineState.getKeylines()).locOffset) + .isGreaterThan((float) carousel.getContainerWidth()); + assertThat(keylineState.getKeylines().get(1).mask).isEqualTo(0F); + assertThat(keylineState.getKeylines().get(2).maskedItemSize).isEqualTo(minSmallItemSize); + } + + @Test + public void testKnownArrangement_correctlyCalculatesKeylineLocations() { + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 400, 200); + + HeroCarouselStrategy config = new HeroCarouselStrategy(); + float extraSmallSize = + view.getResources().getDimension(R.dimen.m3_carousel_gone_size); + float minSmallItemSize = + view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_min); + // Keyline sizes for the hero variant carousel are: + // {extraSmallSize, largeSize, minSmallSize, extraSmallSize} + // The keyline loc offsets are placed so that an item centered on a keyline has the + // keyline size described above. + // The large size is based on whatever width is left over from the minimum small size. + float[] locOffsets = + new float[] { + -extraSmallSize / 2f, + (400 - minSmallItemSize) / 2f, + 400 - minSmallItemSize / 2f, + 400 + extraSmallSize / 2f + }; + + List keylines = + config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(400), view).getKeylines(); + for (int i = 0; i < keylines.size(); i++) { + assertThat(keylines.get(i).locOffset).isEqualTo(locOffsets[i]); + } + } + + @Test + public void testKnownArrangementWithMargins_correctlyCalculatesKeylineLocations() { + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 400, 200); + LayoutParams layoutParams = (LayoutParams) view.getLayoutParams(); + layoutParams.leftMargin += 50; + layoutParams.rightMargin += 30; + + HeroCarouselStrategy config = new HeroCarouselStrategy(); + float extraSmallSize = + view.getResources().getDimension(R.dimen.m3_carousel_gone_size); + float minSmallItemSize = + view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_min); + // Keyline sizes for the hero variant carousel are: + // {extraSmallSize, largeSize, minSmallSize, extraSmallSize} + // The keyline loc offsets are placed so that an item centered on a keyline has the + // keyline size described above. + // The large size is based on whatever width is left over from the minimum small size. + float[] locOffsets = + new float[] { + -(extraSmallSize + 80) / 2f, + (400 - (minSmallItemSize + 80)) / 2f, + 400 - (minSmallItemSize + 80) / 2f, + 400 + (extraSmallSize + 80) / 2f + }; + + List keylines = + config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(400), view).getKeylines(); + for (int i = 0; i < keylines.size(); i++) { + assertThat(keylines.get(i).locOffset).isEqualTo(locOffsets[i]); + } + } +} diff --git a/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java b/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java index dc0ec09e1f8..58b0b588534 100644 --- a/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java +++ b/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java @@ -18,11 +18,10 @@ import com.google.android.material.test.R; import static com.google.android.material.carousel.CarouselHelper.createCarouselWithWidth; +import static com.google.android.material.carousel.CarouselHelper.createViewWithSize; import static com.google.common.truth.Truth.assertThat; -import androidx.recyclerview.widget.RecyclerView.LayoutParams; import android.view.View; -import android.view.View.MeasureSpec; import androidx.test.core.app.ApplicationProvider; import com.google.android.material.carousel.KeylineState.Keyline; import com.google.common.collect.Iterables; @@ -38,7 +37,7 @@ public class MultiBrowseCarouselStrategyTest { @Test public void testOnFirstItemMeasuredWithMargins_createsKeylineStateWithCorrectItemSize() { MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize(200, 200); + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 200, 200); KeylineState keylineState = config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(584), view); @@ -48,7 +47,7 @@ public void testOnFirstItemMeasuredWithMargins_createsKeylineStateWithCorrectIte @Test public void testItemLargerThanContainer_resizesToFit() { MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize(400, 400); + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 400, 400); KeylineState keylineState = config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(100), view); @@ -59,7 +58,7 @@ public void testItemLargerThanContainer_resizesToFit() { public void testItemLargerThanContainerSize_defaultsToOneLargeOneSmall() { Carousel carousel = createCarouselWithWidth(100); MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize(400, 400); + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 400, 400); KeylineState keylineState = config.onFirstChildMeasuredWithMargins(carousel, view); float minSmallItemSize = @@ -81,7 +80,7 @@ public void testKnownArrangementWithMediumItem_correctlyCalculatesKeylineLocatio float[] locOffsets = new float[] {-.5F, 100F, 300F, 464F, 556F, 584.5F}; MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize(200, 200); + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 200, 200); List keylines = config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(584), view).getKeylines(); @@ -95,7 +94,7 @@ public void testKnownArrangementWithoutMediumItem_correctlyCalculatesKeylineLoca float[] locOffsets = new float[] {-.5F, 100F, 300F, 428F, 456.5F}; MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize(200, 200); + View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 200, 200); List keylines = config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(456), view).getKeylines(); @@ -115,7 +114,9 @@ public void testArrangementFit_onlyAdjustsMediumSizeUp() { int carouselSize = (int) (largeSize + mediumSize + smallSize + maxMediumAdjustment); MultiBrowseCarouselStrategy strategy = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize((int) largeSize, (int) largeSize); + View view = + createViewWithSize( + ApplicationProvider.getApplicationContext(), (int) largeSize, (int) largeSize); KeylineState keylineState = strategy.onFirstChildMeasuredWithMargins(createCarouselWithWidth(carouselSize), view); @@ -135,7 +136,9 @@ public void testArrangementFit_onlyAdjustsMediumSizeDown() { int carouselSize = (int) (largeSize + mediumSize + smallSize - maxMediumAdjustment); MultiBrowseCarouselStrategy strategy = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize((int) largeSize, (int) largeSize); + View view = + createViewWithSize( + ApplicationProvider.getApplicationContext(), (int) largeSize, (int) largeSize); KeylineState keylineState = strategy.onFirstChildMeasuredWithMargins(createCarouselWithWidth(carouselSize), view); @@ -146,16 +149,16 @@ public void testArrangementFit_onlyAdjustsMediumSizeDown() { assertThat(keylineState.getKeylines().get(2).maskedItemSize).isLessThan(mediumSize); } - @Test public void testArrangementFit_onlyAdjustsSmallSizeDown() { float largeSize = 56F * 3; float smallSize = 56F; float mediumSize = (largeSize + smallSize) / 2F; - View view = createViewWithSize((int) largeSize, (int) largeSize); - float minSmallSize = - view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_min); + View view = + createViewWithSize( + ApplicationProvider.getApplicationContext(), (int) largeSize, (int) largeSize); + float minSmallSize = view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_min); int carouselSize = (int) (largeSize + mediumSize + minSmallSize); MultiBrowseCarouselStrategy strategy = new MultiBrowseCarouselStrategy(); @@ -174,7 +177,9 @@ public void testArrangementFit_onlyAdjustsSmallSizeUp() { float smallSize = 40F; float mediumSize = (largeSize + smallSize) / 2F; - View view = createViewWithSize((int) largeSize, (int) largeSize); + View view = + createViewWithSize( + ApplicationProvider.getApplicationContext(), (int) largeSize, (int) largeSize); float maxSmallSize = view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_max); int carouselSize = (int) (largeSize + mediumSize + maxSmallSize); @@ -188,13 +193,4 @@ public void testArrangementFit_onlyAdjustsSmallSizeUp() { // Small items should be adjusted to the small size assertThat(keylineState.getKeylines().get(3).maskedItemSize).isEqualTo(maxSmallSize); } - - private static View createViewWithSize(int width, int height) { - View view = new View(ApplicationProvider.getApplicationContext()); - view.setLayoutParams(new LayoutParams(width, height)); - view.measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); - return view; - } }