/* * 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 java.lang.Math.abs; import static java.lang.Math.max; import static java.lang.Math.min; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.annotation.RestrictTo.Scope; import androidx.core.math.MathUtils; /** * A class 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 according to a priority heuristic. */ @RestrictTo(Scope.LIBRARY_GROUP) public final class Arrangement { // 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; final int priority; float smallSize; int smallCount; 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 */ public 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 small items can be * @param targetLargeSize the target size for large items */ 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); } // Zero out small size if there are no small items smallSize = smallCount > 0 ? smallSize : 0F; 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; } /** * Create an arrangement for all possible permutations for {@code smallCounts} 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 arrangement 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 ordered by priority * @param targetMediumSize the size medium items would like to be * @param mediumCounts an array of medium item counts for a valid arrangement ordered by priority * @param targetLargeSize the size large items would like to be * @param largeCounts an array of large item counts for a valid arrangement ordered by priority * @return the arrangement that is considered the most desirable and has been adjusted to fit * within the available space */ @Nullable public static Arrangement findLowestCostArrangement( float availableSpace, float targetSmallSize, float minSmallSize, float maxSmallSize, @NonNull int[] smallCounts, float targetMediumSize, @NonNull int[] mediumCounts, float targetLargeSize, @NonNull 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; } int getItemCount() { return smallCount + mediumCount + largeCount; } }