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