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));