diff --git a/catalog/java/io/material/catalog/carousel/CarouselAdapter.java b/catalog/java/io/material/catalog/carousel/CarouselAdapter.java index 97ff484f81c..0309e7282fb 100644 --- a/catalog/java/io/material/catalog/carousel/CarouselAdapter.java +++ b/catalog/java/io/material/catalog/carousel/CarouselAdapter.java @@ -22,6 +22,7 @@ import androidx.recyclerview.widget.DiffUtil; import android.view.LayoutInflater; import android.view.ViewGroup; +import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; /** An adapter that displays {@link CarouselItem}s for a Carousel. */ @@ -44,10 +45,16 @@ public boolean areContentsTheSame( }; private final CarouselItemListener listener; + @LayoutRes private final int itemLayoutRes; CarouselAdapter(CarouselItemListener listener) { + this(listener, R.layout.cat_carousel_item); + } + + CarouselAdapter(CarouselItemListener listener, @LayoutRes int itemLayoutRes) { super(DIFF_CALLBACK); this.listener = listener; + this.itemLayoutRes = itemLayoutRes; } @NonNull @@ -55,7 +62,7 @@ public boolean areContentsTheSame( public CarouselItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int pos) { return new CarouselItemViewHolder( LayoutInflater.from(viewGroup.getContext()) - .inflate(R.layout.cat_carousel_item, viewGroup, false), listener); + .inflate(itemLayoutRes, viewGroup, false), listener); } @Override diff --git a/catalog/java/io/material/catalog/carousel/MultiBrowseDemoFragment.java b/catalog/java/io/material/catalog/carousel/MultiBrowseDemoFragment.java index 50f1bdf171c..fba625cefe7 100644 --- a/catalog/java/io/material/catalog/carousel/MultiBrowseDemoFragment.java +++ b/catalog/java/io/material/catalog/carousel/MultiBrowseDemoFragment.java @@ -97,7 +97,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle bundle) { CarouselAdapter adapter = new CarouselAdapter( - (item, position) -> multiBrowseStartRecyclerView.scrollToPosition(position)); + (item, position) -> multiBrowseStartRecyclerView.scrollToPosition(position), + R.layout.cat_carousel_item_narrow); itemCountDropdown.setOnItemClickListener( (parent, view1, position, id) -> { diff --git a/catalog/java/io/material/catalog/carousel/res/layout/cat_carousel_item_narrow.xml b/catalog/java/io/material/catalog/carousel/res/layout/cat_carousel_item_narrow.xml new file mode 100644 index 00000000000..0517da0a5c2 --- /dev/null +++ b/catalog/java/io/material/catalog/carousel/res/layout/cat_carousel_item_narrow.xml @@ -0,0 +1,35 @@ + + + + + + diff --git a/lib/java/com/google/android/material/carousel/MultiBrowseCarouselStrategy.java b/lib/java/com/google/android/material/carousel/MultiBrowseCarouselStrategy.java index 4d91e300572..34bb1a911f9 100644 --- a/lib/java/com/google/android/material/carousel/MultiBrowseCarouselStrategy.java +++ b/lib/java/com/google/android/material/carousel/MultiBrowseCarouselStrategy.java @@ -19,8 +19,10 @@ import com.google.android.material.R; import static java.lang.Math.abs; +import static java.lang.Math.ceil; +import static java.lang.Math.floor; import static java.lang.Math.max; -import static java.lang.Math.round; +import static java.lang.Math.min; import android.content.Context; import androidx.recyclerview.widget.RecyclerView.LayoutParams; @@ -28,6 +30,8 @@ import androidx.annotation.NonNull; import androidx.annotation.RestrictTo; import androidx.annotation.RestrictTo.Scope; +import androidx.annotation.VisibleForTesting; +import androidx.core.math.MathUtils; /** * A {@link CarouselStrategy} that knows how to size and fit large, medium and small items into a @@ -44,18 +48,19 @@ */ public final class MultiBrowseCarouselStrategy extends CarouselStrategy { - // The percentage by which a medium item needs to be larger than a small item and smaller - // than an large item. This is used to ensure a medium item is truly somewhere between the - // small and large sizes, making for a visually balanced arrangement. - // 0F would mean a medium item could be >= small item size and <= a large item size. - // .25F means the medium item must be >= 125% of the small item size and <= 75% of the - // large item size. - private static final float MEDIUM_SIZE_PERCENTAGE_DELTA = .25F; + // Specifies a percentage of a medium item's size by which it can be increased or decreased to + // help fit an arrangement into the carousel's available space. + private static final float MEDIUM_ITEM_FLEX_PERCENTAGE = .1F; + + private static final int[] SMALL_COUNTS = new int[] {1}; + private static final int[] MEDIUM_COUNTS = new int[] {1, 0}; + private static final int[] MEDIUM_COUNTS_COMPACT = new int[] {0}; // True if medium items should never be added and arrangements should consist of only large and // small items. This will often result in a greater number of large items but more variability in // large item size. This can be desirable when optimizing for the greatest number of fully // unmasked items visible at once. + // TODO(b/274604170): Remove this option private final boolean forceCompactArrangement; public MultiBrowseCarouselStrategy() { @@ -68,7 +73,6 @@ public MultiBrowseCarouselStrategy() { * @param forceCompactArrangement true if items should be fit in a way that maximizes the number * of large, unmasked items. false if this strategy is free to determine an opinionated * balance between item sizes. - * * @hide */ @RestrictTo(Scope.LIBRARY_GROUP) @@ -80,131 +84,375 @@ private float getExtraSmallSize(@NonNull Context context) { return context.getResources().getDimension(R.dimen.m3_carousel_gone_size); } - private float getSmallSize(@NonNull Context context) { - return context.getResources().getDimension(R.dimen.m3_carousel_small_item_size); + private float getSmallSizeMin(@NonNull Context context) { + return context.getResources().getDimension(R.dimen.m3_carousel_small_item_size_min); + } + + private float getSmallSizeMax(@NonNull Context context) { + return context.getResources().getDimension(R.dimen.m3_carousel_small_item_size_max); } @Override @NonNull - KeylineState onFirstChildMeasuredWithMargins( - @NonNull Carousel carousel, @NonNull View child) { + KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNull View child) { + float availableSpace = carousel.getContainerWidth(); + LayoutParams childLayoutParams = (LayoutParams) child.getLayoutParams(); float childHorizontalMargins = childLayoutParams.leftMargin + childLayoutParams.rightMargin; - float smallChildWidth = getSmallSize(child.getContext()) + childHorizontalMargins; - float extraSmallChildWidth = getExtraSmallSize(child.getContext()) + childHorizontalMargins; - - float availableSpace = carousel.getContainerWidth(); + float smallChildWidthMin = getSmallSizeMin(child.getContext()) + childHorizontalMargins; + float smallChildWidthMax = getSmallSizeMax(child.getContext()) + childHorizontalMargins; - // The minimum viable arrangement is 1 large and 1 small child. A single large item size - // cannot be greater than the available space minus a small child width. - float maxLargeChildSpace = availableSpace - smallChildWidth; - float largeChildWidth = child.getMeasuredWidth() + childHorizontalMargins; - - int largeCount; - int mediumCount; - int smallCount; - float mediumChildWidth; - - if (maxLargeChildSpace <= smallChildWidth) { - // There is not enough space to show a small and a large item. Remove the small item and - // default to showing a single, fullscreen item. - largeCount = 1; - largeChildWidth = availableSpace; - mediumCount = 0; - mediumChildWidth = 0; - smallCount = 0; - } else if (largeChildWidth >= maxLargeChildSpace) { - // There is only enough space to show 1 large, and 1 small item. - largeCount = 1; - largeChildWidth = maxLargeChildSpace; - mediumCount = 0; - mediumChildWidth = 0F; - smallCount = 1; - } else { - // There is enough space for some combination of large items, an optional medium item, - // and a small item. Find the arrangement where large items need to be adjusted in - // size by the least amount. - float mediumChildMinWidth = - smallChildWidth + (smallChildWidth * MEDIUM_SIZE_PERCENTAGE_DELTA); - // TODO: Ensure this is always <= expanded size even after expanded size is adjusted. - float mediumChildMaxWidth = - largeChildWidth - (largeChildWidth * MEDIUM_SIZE_PERCENTAGE_DELTA); - - float largeRangeMin = availableSpace - (mediumChildMaxWidth + smallChildWidth); - float largeRangeMax = availableSpace - (mediumChildMinWidth + smallChildWidth); - - // The standard arrangement is `x` large, 1 medium, and 1 small item where `x` is the - // maximum number of large items that can fit within the available space. - float standardLargeRangeCenter = (largeRangeMin + largeRangeMax) / 2; - float standardLargeQuotient = standardLargeRangeCenter / largeChildWidth; - int standardLargeCount = round(standardLargeQuotient); - float standardLargeChildWidth = largeChildWidth; - // If the largeChildWidth * count falls outside of the large min-max range, the width of - // large children for the standard arrangement needs to be adjusted. Make the smallest - // adjustment possible to bring the number of large children back to fit within the - // available space. - if (largeChildWidth * standardLargeCount < largeRangeMin) { - standardLargeChildWidth = largeRangeMin / standardLargeCount; - } else if (largeChildWidth * standardLargeCount > largeRangeMax) { - standardLargeChildWidth = largeRangeMax / standardLargeCount; - } + float measuredChildWidth = child.getMeasuredWidth(); + 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 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. + float targetSmallChildWidth = + MathUtils.clamp( + measuredChildWidth / 3F + childHorizontalMargins, + getSmallSizeMin(child.getContext()) + childHorizontalMargins, + getSmallSizeMax(child.getContext()) + childHorizontalMargins); + float targetMediumChildWidth = (targetLargeChildWidth + targetSmallChildWidth) / 2F; - // The compact arrangement is `x` large, and 1 small item where `x` is the maximum - // number of large items that can fit within the available space. - float compactLargeQuotient = (availableSpace - smallChildWidth) / largeChildWidth; - int compactLargeCount = round(compactLargeQuotient); - // Adjust the largeChildWidth so largeChildWidth * largeCount fits perfectly within - // the available space. - float compactLargeChildWidth = (availableSpace - smallChildWidth) / compactLargeCount; - - // Use the arrangement type which requires the large item size to be adjusted the least, - // retaining the developer specified item size as much as possible. - if (abs(largeChildWidth - standardLargeChildWidth) - <= abs(largeChildWidth - compactLargeChildWidth) - && !forceCompactArrangement) { - largeCount = standardLargeCount; - largeChildWidth = standardLargeChildWidth; - mediumCount = 1; - mediumChildWidth = availableSpace - (largeChildWidth * largeCount) - smallChildWidth; - smallCount = 1; - } else { - largeCount = compactLargeCount; - largeChildWidth = compactLargeChildWidth; - mediumCount = 0; - mediumChildWidth = 0; - smallCount = 1; - } + // Create arrays representing the possible count of small, medium, and large items. These are + // not in an asc./dec. order but are in order of priority. A small count array of { 2, 3, 1 } + // says that ideally an arrangement with 2 small items is found, then 3 is next most desirable, + // then finally 1. + int[] smallCounts = SMALL_COUNTS; + int[] mediumCounts = forceCompactArrangement ? MEDIUM_COUNTS_COMPACT : MEDIUM_COUNTS; + // 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. + float minAvailableLargeSpace = + availableSpace + - (targetMediumChildWidth * maxValue(mediumCounts)) + - (smallChildWidthMax * maxValue(smallCounts)); + 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] = largeCountMax - i; } + Arrangement arrangement = + findLowestCostArrangement( + availableSpace, + targetSmallChildWidth, + smallChildWidthMin, + smallChildWidthMax, + smallCounts, + targetMediumChildWidth, + mediumCounts, + targetLargeChildWidth, + largeCounts); + + float extraSmallChildWidth = getExtraSmallSize(child.getContext()) + childHorizontalMargins; + float start = 0F; float extraSmallHeadCenterX = start - (extraSmallChildWidth / 2F); - float largeStartCenterX = start + (largeChildWidth / 2F); - float largeEndCenterX = largeStartCenterX + (max(0, largeCount - 1) * largeChildWidth); - start = largeEndCenterX + largeChildWidth / 2F; + float largeStartCenterX = start + (arrangement.largeSize / 2F); + float largeEndCenterX = + largeStartCenterX + (max(0, arrangement.largeCount - 1) * arrangement.largeSize); + start = largeEndCenterX + arrangement.largeSize / 2F; - float mediumCenterX = mediumCount > 0 ? start + (mediumChildWidth / 2F) : largeEndCenterX; - start = mediumCount > 0 ? mediumCenterX + (mediumChildWidth / 2F) : start; + float mediumCenterX = + arrangement.mediumCount > 0 ? start + (arrangement.mediumSize / 2F) : largeEndCenterX; + start = arrangement.mediumCount > 0 ? mediumCenterX + (arrangement.mediumSize / 2F) : start; - float smallStartCenterX = smallCount > 0 ? start + (smallChildWidth / 2F) : mediumCenterX; + float smallStartCenterX = + arrangement.smallCount > 0 ? start + (arrangement.smallSize / 2F) : mediumCenterX; float extraSmallTailCenterX = carousel.getContainerWidth() + (extraSmallChildWidth / 2F); float extraSmallMask = - getChildMaskPercentage(extraSmallChildWidth, largeChildWidth, childHorizontalMargins); + getChildMaskPercentage(extraSmallChildWidth, arrangement.largeSize, childHorizontalMargins); float smallMask = - getChildMaskPercentage(smallChildWidth, largeChildWidth, childHorizontalMargins); + getChildMaskPercentage( + arrangement.smallSize, arrangement.largeSize, childHorizontalMargins); float mediumMask = - getChildMaskPercentage(mediumChildWidth, largeChildWidth, childHorizontalMargins); + getChildMaskPercentage( + arrangement.mediumSize, arrangement.largeSize, childHorizontalMargins); float largeMask = 0F; - return new KeylineState.Builder(largeChildWidth) - .addKeyline(extraSmallHeadCenterX, extraSmallMask, extraSmallChildWidth) - .addKeylineRange(largeStartCenterX, largeMask, largeChildWidth, largeCount, true) - .addKeyline(mediumCenterX, mediumMask, mediumChildWidth) - .addKeylineRange(smallStartCenterX, smallMask, smallChildWidth, smallCount) - .addKeyline(extraSmallTailCenterX, extraSmallMask, extraSmallChildWidth) - .build(); + KeylineState.Builder builder = + new KeylineState.Builder(arrangement.largeSize) + .addKeyline(extraSmallHeadCenterX, extraSmallMask, extraSmallChildWidth) + .addKeylineRange( + largeStartCenterX, largeMask, arrangement.largeSize, arrangement.largeCount, true); + if (arrangement.mediumCount > 0) { + builder.addKeyline(mediumCenterX, mediumMask, arrangement.mediumSize); + } + if (arrangement.smallCount > 0) { + builder.addKeylineRange( + smallStartCenterX, smallMask, arrangement.smallSize, arrangement.smallCount); + } + builder.addKeyline(extraSmallTailCenterX, extraSmallMask, extraSmallChildWidth); + return builder.build(); + } + + /** + * Create an arrangement for all possible permutations for {@code smallCounts}, {@code + * mediumCounts}, and {@code largeCounts}, fit each into the available space, and return the + * arrangement with the lowest cost. + * + *

Keep in mind that the returned arrangements do not take into account the available space + * from the carousel. They will all occupy varying degrees of more or less space. The caller needs + * to handle sorting the returned list, picking the most desirable arrangement, and fitting the + * arrangement to the size of the carousel. + * + * @param availableSpace the space the arrangmenet needs to fit + * @param targetSmallSize the size small items would like to be + * @param minSmallSize the minimum size small items are allowed to be + * @param maxSmallSize the maximum size small items are allowed to be + * @param smallCounts an array of small item counts for a valid arrangement + * @param targetMediumSize the size medium items would like to be + * @param mediumCounts an array of medium item counts for a valid arrangement + * @param targetLargeSize the size large items would like to be + * @param largeCounts an array of large item counts for a valid arrangement + * @return the arrangement that is considered the most desirable and has been adjusted to fit + * within the available space + */ + private static Arrangement findLowestCostArrangement( + float availableSpace, + float targetSmallSize, + float minSmallSize, + float maxSmallSize, + int[] smallCounts, + float targetMediumSize, + int[] mediumCounts, + float targetLargeSize, + int[] largeCounts) { + Arrangement lowestCostArrangement = null; + int priority = 1; + for (int largeCount : largeCounts) { + for (int mediumCount : mediumCounts) { + for (int smallCount : smallCounts) { + Arrangement arrangement = + new Arrangement( + priority, + targetSmallSize, + minSmallSize, + maxSmallSize, + smallCount, + targetMediumSize, + mediumCount, + targetLargeSize, + largeCount, + availableSpace); + if (lowestCostArrangement == null || arrangement.cost < lowestCostArrangement.cost) { + lowestCostArrangement = arrangement; + if (lowestCostArrangement.cost == 0F) { + // If the new lowestCostArrangement has a cost of 0, we know it didn't have to alter + // the large item size at all. We also know that arrangement permutations will be + // generated in order of priority. We can exit early knowing there will not be an + // arrangement with a better cost or priority. + return lowestCostArrangement; + } + } + priority++; + } + } + } + return lowestCostArrangement; + } + + private static int maxValue(int[] array) { + int largest = Integer.MIN_VALUE; + for (int j : array) { + if (j > largest) { + largest = j; + } + } + + return largest; + } + + /** + * An object that holds data about a combination of large, medium, and small items, knows how to + * alter an arrangement to fit within an available space, and can assess the arrangement's + * desirability. + */ + @VisibleForTesting + static final class Arrangement { + final int priority; + float smallSize; + final int smallCount; + final int mediumCount; + float mediumSize; + float largeSize; + final int largeCount; + final float cost; + + /** + * Creates a new arrangement by taking in a number of small, medium, and large items and the + * size each would like to be and then fitting the sizes to work within the {@code + * availableSpace}. + * + *

Note: The values for each item size after construction will likely differ from the target + * values passed to the constructor since the constructor handles altering the sizes until the + * total count is able to fit within the space see {@link #fit(float, float, float, float)} for + * more details. + * + * @param priority the order in which this arrangement should be preferred against other + * arrangements that fit + * @param targetSmallSize the size of a small item in this arrangement + * @param minSmallSize the minimum size a small item is allowed to be + * @param maxSmallSize the maximum size a small item is allowed to be + * @param smallCount the number of small items in this arrangement + * @param targetMediumSize the size of medium items in this arrangement + * @param mediumCount the number of medium items in this arrangement + * @param targetLargeSize the size of large items in this arrangement + * @param largeCount the number of large items in this arrangement + * @param availableSpace the space this arrangement needs to fit within + */ + Arrangement( + int priority, + float targetSmallSize, + float minSmallSize, + float maxSmallSize, + int smallCount, + float targetMediumSize, + int mediumCount, + float targetLargeSize, + int largeCount, + float availableSpace) { + this.priority = priority; + this.smallSize = MathUtils.clamp(targetSmallSize, minSmallSize, maxSmallSize); + this.smallCount = smallCount; + this.mediumSize = targetMediumSize; + this.mediumCount = mediumCount; + this.largeSize = targetLargeSize; + this.largeCount = largeCount; + + fit(availableSpace, minSmallSize, maxSmallSize, targetLargeSize); + this.cost = cost(targetLargeSize); + } + + @NonNull + @Override + public String toString() { + return "Arrangement [priority=" + + priority + + ", smallCount=" + + smallCount + + ", smallSize=" + + smallSize + + ", mediumCount=" + + mediumCount + + ", mediumSize=" + + mediumSize + + ", largeCount=" + + largeCount + + ", largeSize=" + + largeSize + + ", cost=" + + cost + + "]"; + } + + /** Gets the total space taken by this arrangement. */ + private float getSpace() { + return (largeSize * largeCount) + (mediumSize * mediumCount) + (smallSize * smallCount); + } + + /** + * Alters the item sizes of this arrangement until the space occupied fits within the {@code + * availableSpace}. + * + *

This method tries to adjust the size of large items as little as possible by first + * adjusting small items as much as possible, then adjusting medium items as much as possible, + * and finally adjusting large items if the arrangement is still unable to fit. + * + * @param availableSpace the size of the carousel this arrangement needs to fit + * @param minSmallSize the minimum size small items can be + * @param maxSmallSize the maximum size medium items can be + */ + private void fit( + float availableSpace, float minSmallSize, float maxSmallSize, float targetLargeSize) { + float delta = availableSpace - getSpace(); + // First, resize small items within their allowable min-max range to try to fit the + // arrangement into the available space. + if (smallCount > 0 && delta > 0) { + // grow the small items + smallSize += min(delta / smallCount, maxSmallSize - smallSize); + } else if (smallCount > 0 && delta < 0) { + // shrink the small items + smallSize += max(delta / smallCount, minSmallSize - smallSize); + } + + largeSize = + calculateLargeSize(availableSpace, smallCount, smallSize, mediumCount, largeCount); + mediumSize = (largeSize + smallSize) / 2F; + + // If the large size has been adjusted away from its target size to fit the arrangement, + // counter this as much as possible by altering the medium item within its acceptable flex + // range. + if (mediumCount > 0 && largeSize != targetLargeSize) { + float targetAdjustment = (targetLargeSize - largeSize) * largeCount; + float availableMediumFlex = (mediumSize * MEDIUM_ITEM_FLEX_PERCENTAGE) * mediumCount; + float distribute = min(abs(targetAdjustment), availableMediumFlex); + if (targetAdjustment > 0F) { + // Reduce the size of the medium item and give it back to the large items + mediumSize -= (distribute / mediumCount); + largeSize += (distribute / largeCount); + } else { + // Increase the size of the medium item and take from the large items + mediumSize += (distribute / mediumCount); + largeSize -= (distribute / largeCount); + } + } + } + + /** + * Calculates the large size that is able to fit within the available space given item counts, + * the small size, and that the medium size is {@code (largeSize + smallSize) / 2}. + * + *

This method solves the following equation for largeSize: + * + *

{@code availableSpace = (largeSize * largeCount) + (((largeSize + smallSize) / 2) * + * mediumCount) + (smallSize * smallCount)} + * + * @param availableSpace the total available space + * @param smallCount the number of small items in the arrangement + * @param smallSize the size of small items in the arrangement + * @param mediumCount the number of medium items in the arrangement + * @param largeCount the number of large items in the arrangement + * @return the large item size which will fit for the available space and other item constraints + */ + private float calculateLargeSize( + float availableSpace, int smallCount, float smallSize, int mediumCount, int largeCount) { + // Zero out small size if there are no small items + smallSize = smallCount > 0 ? smallSize : 0F; + return (availableSpace - (((float) smallCount) + ((float) mediumCount) / 2F) * smallSize) + / (((float) largeCount) + ((float) mediumCount) / 2F); + } + + private boolean isValid() { + if (largeCount > 0 && smallCount > 0 && mediumCount > 0) { + return largeSize > mediumSize && mediumSize > smallSize; + } else if (largeCount > 0 && smallCount > 0) { + return largeSize > smallSize; + } + + return true; + } + + /** + * Calculates the cost of this arrangement to determine visual desirability and adherence to + * inputs. + * + * @param targetLargeSize the size large items would like to be + * @return a float representing the cost of this arrangement where the lower the cost the better + */ + private float cost(float targetLargeSize) { + if (!isValid()) { + return Float.MAX_VALUE; + } + // Arrangements have a lower cost if they have a priority closer to 1 and their largeSize is + // altered as little as possible. + return abs(targetLargeSize - largeSize) * priority; + } } } diff --git a/lib/java/com/google/android/material/carousel/res/values/dimens.xml b/lib/java/com/google/android/material/carousel/res/values/dimens.xml index 93da3891ba4..3f14cfd82b4 100644 --- a/lib/java/com/google/android/material/carousel/res/values/dimens.xml +++ b/lib/java/com/google/android/material/carousel/res/values/dimens.xml @@ -18,7 +18,8 @@ 28dp 2dp - 56dp + 40dp + 56dp 10dp 1dp diff --git a/lib/javatests/com/google/android/material/carousel/ArrangementTest.java b/lib/javatests/com/google/android/material/carousel/ArrangementTest.java new file mode 100644 index 00000000000..4c6233f2f37 --- /dev/null +++ b/lib/javatests/com/google/android/material/carousel/ArrangementTest.java @@ -0,0 +1,265 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import com.google.android.material.carousel.MultiBrowseCarouselStrategy.Arrangement; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.internal.DoNotInstrument; + +/** Tests for {@link MultiBrowseCarouselStrategy.Arrangement}. */ +@RunWith(RobolectricTestRunner.class) +@DoNotInstrument +public final class ArrangementTest { + + @Test + public void test1L1M1S_noAdjustmentsMade() { + float targetSmallSize = 56F; + float targetLargeSize = 56F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 1, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 1, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 1, + /* availableSpace= */ targetLargeSize + targetMediumSize + targetSmallSize); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(arrangement.mediumSize).isEqualTo(targetMediumSize); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize); + } + + @Test + public void test1L1M1S_decreasesSmallSize() { + float targetSmallSize = 56F; + float targetLargeSize = 56F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 1, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 1, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 1, + /* availableSpace= */ targetLargeSize + targetMediumSize + targetSmallSize - 10F); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)).isEqualTo(Math.round(targetMediumSize)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize - 10F); + } + + @Test + public void test1L1M1S_increasesSmallSize() { + float targetSmallSize = 40F; + float targetLargeSize = 40F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 1, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 1, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 1, + /* availableSpace= */ targetLargeSize + targetMediumSize + targetSmallSize + 10F); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)).isEqualTo(Math.round(targetMediumSize)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize + 10F); + } + + @Test + public void test1L1M1S_decreasesMediumSize() { + float targetSmallSize = 40F; + float targetLargeSize = 40F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + float mediumAdjustment = targetMediumSize * .05F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 1, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 1, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 1, + /* availableSpace= */ targetLargeSize + + targetMediumSize + + targetSmallSize + - mediumAdjustment); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)) + .isEqualTo(Math.round(targetMediumSize - mediumAdjustment)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize); + } + + @Test + public void test1L1M1S_increasesMediumSize() { + float targetSmallSize = 56F; + float targetLargeSize = 56F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + float mediumAdjustment = targetMediumSize * .05F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 1, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 1, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 1, + /* availableSpace= */ targetLargeSize + + targetMediumSize + + targetSmallSize + + mediumAdjustment); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)) + .isEqualTo(Math.round(targetMediumSize + mediumAdjustment)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize); + } + + @Test + public void test1L1M2S_increasesSmallSize() { + float targetSmallSize = 40F; + float targetLargeSize = 40F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + float smallAdjustment = 10F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 2, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 1, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 1, + /* availableSpace= */ targetLargeSize + + targetMediumSize + + (targetSmallSize * 2) + + (smallAdjustment * 2)); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)).isEqualTo(Math.round(targetMediumSize)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize + smallAdjustment); + } + + @Test + public void test1L1M2S_decreasesSmallSize() { + float targetSmallSize = 56F; + float targetLargeSize = 56F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + float smallAdjustment = 10F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 2, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 1, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 1, + /* availableSpace= */ targetLargeSize + + targetMediumSize + + (targetSmallSize * 2) + - (smallAdjustment * 2)); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)).isEqualTo(Math.round(targetMediumSize)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize - smallAdjustment); + } + + @Test + public void test2L2M2S_increasesMediumSize() { + float targetSmallSize = 56F; + float targetLargeSize = 56F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + float mediumAdjustment = targetMediumSize * .05F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 2, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 2, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 2, + /* availableSpace= */ (targetLargeSize * 2) + + (targetMediumSize * 2) + + (targetSmallSize * 2) + + (mediumAdjustment * 2)); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)) + .isEqualTo(Math.round(targetMediumSize + mediumAdjustment)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize); + } + + @Test + public void test2L2M2S_decreasesMediumSize() { + float targetSmallSize = 40F; + float targetLargeSize = 40F * 3F; + float targetMediumSize = (targetLargeSize + targetSmallSize) / 2F; + float mediumAdjustment = targetMediumSize * .05F; + Arrangement arrangement = + new Arrangement( + /* priority= */ 1, + /* targetSmallSize= */ targetSmallSize, + /* minSmallSize= */ 40F, + /* maxSmallSize= */ 56F, + /* smallCount= */ 2, + /* targetMediumSize= */ targetMediumSize, + /* mediumCount= */ 2, + /* targetLargeSize= */ targetLargeSize, + /* largeCount= */ 2, + /* availableSpace= */ (targetLargeSize * 2) + + (targetMediumSize * 2) + + (targetSmallSize * 2) + - (mediumAdjustment * 2)); + + assertThat(arrangement.largeSize).isEqualTo(targetLargeSize); + assertThat(Math.round(arrangement.mediumSize)) + .isEqualTo(Math.round(targetMediumSize - mediumAdjustment)); + assertThat(arrangement.smallSize).isEqualTo(targetSmallSize); + } +} diff --git a/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java b/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java index c0816850ec4..dc0ec09e1f8 100644 --- a/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java +++ b/lib/javatests/com/google/android/material/carousel/MultiBrowseCarouselStrategyTest.java @@ -15,6 +15,8 @@ */ 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.common.truth.Truth.assertThat; @@ -36,11 +38,11 @@ public class MultiBrowseCarouselStrategyTest { @Test public void testOnFirstItemMeasuredWithMargins_createsKeylineStateWithCorrectItemSize() { MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize(450, 450); + View view = createViewWithSize(200, 200); KeylineState keylineState = - config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(2470), view); - assertThat(keylineState.getItemSize()).isEqualTo(450F); + config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(584), view); + assertThat(keylineState.getItemSize()).isEqualTo(200F); } @Test @@ -54,37 +56,139 @@ public void testItemLargerThanContainer_resizesToFit() { } @Test - public void testItemLargerThanContainerSize_defaultsToFullscreen() { + public void testItemLargerThanContainerSize_defaultsToOneLargeOneSmall() { Carousel carousel = createCarouselWithWidth(100); MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); View view = createViewWithSize(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 [collapsed-expanded-collapsed] where the collapsed items are - // outside the bounds of the carousel container and the expanded center item takes up the + // 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(3); + 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() { - float[] locOffsets = new float[] {-.5F, 225F, 675F, 942F, 1012F, 1040.5F}; + public void testKnownArrangementWithMediumItem_correctlyCalculatesKeylineLocations() { + float[] locOffsets = new float[] {-.5F, 100F, 300F, 464F, 556F, 584.5F}; MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); - View view = createViewWithSize(450, 450); + View view = createViewWithSize(200, 200); List keylines = - config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(1040), view).getKeylines(); + config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(584), view).getKeylines(); for (int i = 0; i < keylines.size(); i++) { assertThat(keylines.get(i).locOffset).isEqualTo(locOffsets[i]); } } + @Test + public void testKnownArrangementWithoutMediumItem_correctlyCalculatesKeylineLocations() { + float[] locOffsets = new float[] {-.5F, 100F, 300F, 428F, 456.5F}; + + MultiBrowseCarouselStrategy config = new MultiBrowseCarouselStrategy(); + View view = createViewWithSize(200, 200); + + List keylines = + config.onFirstChildMeasuredWithMargins(createCarouselWithWidth(456), view).getKeylines(); + for (int i = 0; i < keylines.size(); i++) { + assertThat(keylines.get(i).locOffset).isEqualTo(locOffsets[i]); + } + } + + @Test + public void testArrangementFit_onlyAdjustsMediumSizeUp() { + float largeSize = 56F * 3F; + float smallSize = 56F; + float mediumSize = (largeSize + smallSize) / 2F; + float maxMediumAdjustment = mediumSize * .1F; + // Create a carousel that is larger than 1 of each items added together but within the range of + // the medium item being able to flex to fit the space. + int carouselSize = (int) (largeSize + mediumSize + smallSize + maxMediumAdjustment); + + MultiBrowseCarouselStrategy strategy = new MultiBrowseCarouselStrategy(); + View view = createViewWithSize((int) largeSize, (int) largeSize); + KeylineState keylineState = + strategy.onFirstChildMeasuredWithMargins(createCarouselWithWidth(carouselSize), view); + + // Large and small items should not be adjusted in size by the strategy + assertThat(keylineState.getKeylines().get(1).maskedItemSize).isEqualTo(largeSize); + assertThat(keylineState.getKeylines().get(3).maskedItemSize).isEqualTo(smallSize); + // The medium item should use its flex to fit the arrangement + assertThat(keylineState.getKeylines().get(2).maskedItemSize).isGreaterThan(mediumSize); + } + + @Test + public void testArrangementFit_onlyAdjustsMediumSizeDown() { + float largeSize = 40F * 3F; + float smallSize = 40F; + float mediumSize = (largeSize + smallSize) / 2F; + float maxMediumAdjustment = mediumSize * .1F; + int carouselSize = (int) (largeSize + mediumSize + smallSize - maxMediumAdjustment); + + MultiBrowseCarouselStrategy strategy = new MultiBrowseCarouselStrategy(); + View view = createViewWithSize((int) largeSize, (int) largeSize); + KeylineState keylineState = + strategy.onFirstChildMeasuredWithMargins(createCarouselWithWidth(carouselSize), view); + + // Large and small items should not be adjusted in size by the strategy + assertThat(keylineState.getKeylines().get(1).maskedItemSize).isEqualTo(largeSize); + assertThat(keylineState.getKeylines().get(3).maskedItemSize).isEqualTo(smallSize); + // The medium item should use its flex to fit the arrangement + 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); + int carouselSize = (int) (largeSize + mediumSize + minSmallSize); + + MultiBrowseCarouselStrategy strategy = new MultiBrowseCarouselStrategy(); + KeylineState keylineState = + strategy.onFirstChildMeasuredWithMargins(createCarouselWithWidth(carouselSize), view); + + // Large items should not change + assertThat(keylineState.getKeylines().get(1).maskedItemSize).isEqualTo(largeSize); + // Small items should be adjusted to the small size + assertThat(keylineState.getKeylines().get(3).maskedItemSize).isEqualTo(minSmallSize); + } + + @Test + public void testArrangementFit_onlyAdjustsSmallSizeUp() { + float largeSize = 40F * 3; + float smallSize = 40F; + float mediumSize = (largeSize + smallSize) / 2F; + + View view = createViewWithSize((int) largeSize, (int) largeSize); + float maxSmallSize = + view.getResources().getDimension(R.dimen.m3_carousel_small_item_size_max); + int carouselSize = (int) (largeSize + mediumSize + maxSmallSize); + + MultiBrowseCarouselStrategy strategy = new MultiBrowseCarouselStrategy(); + KeylineState keylineState = + strategy.onFirstChildMeasuredWithMargins(createCarouselWithWidth(carouselSize), view); + + // Large items should not change + assertThat(keylineState.getKeylines().get(1).maskedItemSize).isEqualTo(largeSize); + // 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));