1011 lines
41 KiB
Java

/*
* Copyright 2022 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 com.google.android.material.R;
import static com.google.android.material.animation.AnimationUtils.lerp;
import static java.lang.Math.abs;
import static java.lang.Math.max;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.LayoutManager;
import androidx.recyclerview.widget.RecyclerView.LayoutParams;
import androidx.recyclerview.widget.RecyclerView.Recycler;
import androidx.recyclerview.widget.RecyclerView.State;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.core.graphics.ColorUtils;
import androidx.core.math.MathUtils;
import androidx.core.util.Preconditions;
import androidx.core.view.ViewCompat;
import com.google.android.material.carousel.KeylineState.Keyline;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* A {@link LayoutManager} that can mask and offset items along the scrolling axis, creating a
* unique list optimized for a stylized viewing experience.
*
* <p>Carousels require all children to be the same size and no larger than the size of the {@link
* RecyclerView}. Typically, the first item in the adapter will be measured and used as the size for
* all other children. Adapters can have multiple different ViewHolders but each View will be
* measured and laid out using the dimensions received from the first child. This can differ
* depending on the {@link CarouselConfiguration} implementation which can alternatively choose to
* set child dimensions based on available space, ignoring child size all together.
*
* <p>{@link CarouselLayoutManager} fills the scroll container as if items were laid out end-to-end.
* A child's position between {@link Keyline}s (points along the scroll axis) is then used as a
* fraction to animate child properties like offset and masking.
*/
public class CarouselLayoutManager extends LayoutManager implements Carousel {
private int horizontalScrollOffset;
// Min scroll is the offset number that offsets the list to the right/bottom as much as possible.
// In LTR layouts, this will be the scroll offset to move to the start of the container. In RTL,
// this will move the list to the end of the container.
private int minHorizontalScroll;
// Max scroll is the offset number that moves the list to the left/top of the list as much as
// possible. In LTR layouts, this will move the list to the end of the container. In RTL, this
// will move the list to the start of the container.
private int maxHorizontalScroll;
private final DebugItemDecoration debugItemDecoration = new DebugItemDecoration();
@NonNull private CarouselConfiguration config;
@Nullable private KeylineStateList keylineStateList;
// A KeylineState shifted for any current scroll offset.
@Nullable private KeylineState currentKeylineState;
// Tracks the last position to be at child index 0 after the most recent call to #fill. This helps
// optimize fill loops by starting the fill from an adapter position that will need the least
// number of loop iterations to fill the RecyclerView.
private int currentFillStartPosition = 0;
/**
* An internal object used to store and run checks on a child to be potentially added to the
* RecyclerView and laid out.
*/
private static final class ChildCalculations {
View child;
float locOffset;
KeylineRange range;
/**
* Creates new calculations object.
*
* @param child The child being calculated for
* @param locOffset the offset location along the scrolling axis where this child will be laid
* out
* @param range the keyline range that surrounds {@code locOffset}
*/
ChildCalculations(View child, float locOffset, KeylineRange range) {
this.child = child;
this.locOffset = locOffset;
this.range = range;
}
}
public CarouselLayoutManager() {
setCarouselConfiguration(new MultiBrowseCarouselConfiguration(this));
}
public CarouselLayoutManager(
@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
// TODO(b/238620200): Add and obtain carousel attrs set on RecyclerView
}
@Override
public LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
public void setCarouselConfiguration(@NonNull CarouselConfiguration carouselConfiguration) {
this.config = carouselConfiguration;
this.keylineStateList = null;
requestLayout();
}
@Override
public void onLayoutChildren(Recycler recycler, State state) {
if (state.getItemCount() <= 0) {
removeAndRecycleAllViews(recycler);
return;
}
boolean isRtl = isLayoutRtl();
// If a keyline state hasn't been created, use the first child as a representative of how each
// child would like to be measured and allow the config to create a keyline state.
boolean isInitialLoad = keylineStateList == null;
if (isInitialLoad) {
View firstChild = recycler.getViewForPosition(0);
measureChildWithMargins(firstChild, 0, 0);
KeylineState keylineState = config.onFirstChildMeasuredWithMargins(firstChild);
keylineStateList =
KeylineStateList.from(this, isRtl ? KeylineState.reverse(keylineState) : keylineState);
}
// Ensure our scroll limits are initialized and valid for the data set size.
int startHorizontalScroll = calculateStartHorizontalScroll(keylineStateList);
int endHorizontalScroll = calculateEndHorizontalScroll(state, keylineStateList);
// Convert the layout-direction-aware offsets into min/max absolutes. These need to be in the
// min/max format so they can be correctly passed to KeylineStateList and used to interpolate
// between keyline states.
minHorizontalScroll = isRtl ? endHorizontalScroll : startHorizontalScroll;
maxHorizontalScroll = isRtl ? startHorizontalScroll : endHorizontalScroll;
if (isInitialLoad) {
// Scroll to the start of the list on first load.
horizontalScrollOffset = startHorizontalScroll;
} else {
// Clamp the horizontal scroll offset by the new min and max by pinging the scroll by
// calculator with a 0 delta.
horizontalScrollOffset +=
calculateShouldHorizontallyScrollBy(
0, horizontalScrollOffset, minHorizontalScroll, maxHorizontalScroll);
}
updateCurrentKeylineStateForScrollOffset();
detachAndScrapAttachedViews(recycler);
fill(recycler, state);
}
/**
* Adds and places children into the {@link RecyclerView}, handling child layout and recycling
* according to this class' {@link CarouselConfiguration}.
*
* <p>This method is responsible for making sure views are added when additional space is created
* due to an initial layout or a scroll event. All offsetting due to scroll events is done by
* {@link #scrollBy(int, Recycler, State)}.
*
* @param recycler current recycler that is attached to the {@link RecyclerView}
* @param state state passed by the {@link RecyclerView} with useful information like item count
* and focal state
*/
private void fill(Recycler recycler, State state) {
removeAndRecycleOutOfBoundsViews(recycler);
if (getChildCount() == 0) {
// First layout or the data set has changed. Re-layout all views by filling from start to end.
addViewsStart(recycler, currentFillStartPosition - 1);
addViewsEnd(recycler, state, currentFillStartPosition);
} else {
// Fill the container where there is now empty space after scrolling.
int firstPosition = getPosition(getChildAt(0));
int lastPosition = getPosition(getChildAt(getChildCount() - 1));
addViewsStart(recycler, firstPosition - 1);
addViewsEnd(recycler, state, lastPosition + 1);
}
}
@Override
public void onLayoutCompleted(State state) {
super.onLayoutCompleted(state);
if (getChildCount() == 0) {
currentFillStartPosition = 0;
} else {
currentFillStartPosition = getPosition(getChildAt(0));
}
}
/**
* Adds views to the RecyclerView, moving towards the start of the carousel container, until
* potentially new items are no longer in bounds or the beginning of the adapter list is reached.
*
* @param recycler current recycler that is attached to the {@link RecyclerView}
* @param startPosition the adapter position from which to start adding views
*/
private void addViewsStart(Recycler recycler, int startPosition) {
int start = calculateChildStartForFill(startPosition);
for (int i = startPosition; i >= 0; i--) {
ChildCalculations calculations = makeChildCalculations(recycler, start, i);
if (isLocOffsetOutOfFillBoundsStart(calculations.locOffset, calculations.range)) {
break;
}
start = addStart(start, (int) currentKeylineState.getItemSize());
// If this child's start is beyond the end of the container, don't add the child but continue
// to loop so we can eventually get to children that are within bounds.
if (isLocOffsetOutOfFillBoundsEnd(calculations.locOffset, calculations.range)) {
continue;
}
addAndLayoutView(calculations.child, calculations.locOffset);
}
}
/**
* Adds views to the RecyclerView, moving towards the end of the carousel container, until
* potentially new items are no longer in bounds or the end of the adapter list is reached.
*
* @param recycler current recycler that is attached to the {@link RecyclerView}
* @param state state passed by the {@link RecyclerView} used here to determine item count
* @param startPosition the adapter position from which to start adding views
*/
private void addViewsEnd(Recycler recycler, State state, int startPosition) {
int start = calculateChildStartForFill(startPosition);
for (int i = startPosition; i < state.getItemCount(); i++) {
ChildCalculations calculations = makeChildCalculations(recycler, start, i);
if (isLocOffsetOutOfFillBoundsEnd(calculations.locOffset, calculations.range)) {
break;
}
start = addEnd(start, (int) currentKeylineState.getItemSize());
// If this child's end is beyond the start of the container, don't add the child but continue
// to loop so we can eventually get to children that are within bounds.
if (isLocOffsetOutOfFillBoundsStart(calculations.locOffset, calculations.range)) {
continue;
}
addAndLayoutView(calculations.child, calculations.locOffset);
}
}
/**
* Calculates position and mask for a view at at adapter {@code position} and returns an object
* with the calculated values.
*
* <p>The returned object is used to run any checks/validations around whether or not this child
* should be added to the RecyclerView given its calculated location.
*
* @param recycler current recycler that is attached to the {@link RecyclerView}
* @param start the start location of this items view in the end-to-end layout model
* @param position the adapter position of the item to add
* @return a {@link ChildCalculations} object
*/
private ChildCalculations makeChildCalculations(Recycler recycler, float start, int position) {
float halfItemSize = currentKeylineState.getItemSize() / 2F;
View child = recycler.getViewForPosition(position);
measureChildWithMargins(child, 0, 0);
float centerX = addEnd((int) start, (int) halfItemSize);
KeylineRange range =
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, false);
float offsetCx = calculateChildOffsetCenterForLocation(child, centerX, range);
updateChildMaskForLocation(child, centerX, range);
return new ChildCalculations(child, offsetCx, range);
}
/**
* Adds a child to the RecyclerView and lays it out with its center at {@code offsetCx} on the
* scrolling axis.
*
* @param child the child view to add and lay out
* @param offsetCx where the center of the masked child should be placed along the scrolling axis
*/
private void addAndLayoutView(View child, float offsetCx) {
float halfItemSize = currentKeylineState.getItemSize() / 2F;
addView(child);
layoutDecoratedWithMargins(
child,
/* left= */ (int) (offsetCx - halfItemSize),
/* top= */ getParentTop(),
/* right= */ (int) (offsetCx + halfItemSize),
/* bottom= */ getParentBottom());
}
/**
* Returns true if a view rendered at {@code locOffset} will be completely out of bounds (its end
* comes before the start of the container) when masked according to the {@code KeylineRange}.
*
* <p>Use this method to determine whether or not a child whose center is at {@code locOffset}
* should be added to the RecyclerView.
*
* @param locOffset the center of the view to be checked along the scroll axis
* @param range the keyline range surrounding {@code locOffset}
* @return true if the end of a masked view, whose center is at {@code locOffset}, will come
* before the start of the container.
*/
private boolean isLocOffsetOutOfFillBoundsStart(float locOffset, KeylineRange range) {
float maskedSize = getMaskedItemSizeForLocOffset(locOffset, range);
int maskedEnd = addEnd((int) locOffset, (int) (maskedSize / 2));
return isLayoutRtl() ? maskedEnd > getContainerWidth() : maskedEnd < 0;
}
/**
* Returns true if a view rendered at {@code locOffset} will be completely out of bounds (its
* start comes after the end of the container) when masked according to the {@code KeylineRange}.
*
* <p>Use this method to determine whether or not a child whose center is at {@code locOffset}
* should be added to the RecyclerView.
*
* @param locOffset the center of the view to be checked along the scroll axis
* @param range the keyline range surrounding {@code locOffset}
* @return true if the start of a masked view, whose center is at {@code locOffset}, will come
* after the start of the container.
*/
private boolean isLocOffsetOutOfFillBoundsEnd(float locOffset, KeylineRange range) {
float maskedSize = getMaskedItemSizeForLocOffset(locOffset, range);
int maskedStart = addStart((int) locOffset, (int) (maskedSize / 2));
return isLayoutRtl() ? maskedStart < 0 : maskedStart > getContainerWidth();
}
/**
* Returns the masked, decorated bounds with margins for {@code view}.
*
* <p>Note that this differs from the super method which returns the fully unmasked bounds of
* {@code view}.
*
* <p>Getting the masked, decorated bounds is useful for item decorations and other associated
* classes which need the actual visual bounds of an item in the RecyclerView. If the full,
* unmasked bounds is needed, see {@link RecyclerView#getDecoratedBoundsWithMargins(View, Rect)}.
*
* @param view the view element to check
* @param outBounds a rect that will receive the bounds of the element including its maks,
* decoration, and margins.
*/
@Override
public void getDecoratedBoundsWithMargins(@NonNull View view, @NonNull Rect outBounds) {
super.getDecoratedBoundsWithMargins(view, outBounds);
float centerX = outBounds.centerX();
float maskedSize =
getMaskedItemSizeForLocOffset(
centerX, getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, true));
float delta = (outBounds.width() - maskedSize) / 2F;
outBounds.set(
(int) (outBounds.left + delta),
outBounds.top,
(int) (outBounds.right - delta),
outBounds.bottom);
}
private float getDecoratedCenterXWithMargins(View child) {
Rect bounds = new Rect();
super.getDecoratedBoundsWithMargins(child, bounds);
return bounds.centerX();
}
/**
* Remove and recycle any views outside of the bounds of this carousel.
*
* <p>This method uses two loops, one starting from the head of the list and one from the tail.
* This tries to check as few items as necessary before finding the first head or tail child that
* is in bounds.
*
* @param recycler current recycler that is attached to the {@link RecyclerView}
*/
private void removeAndRecycleOutOfBoundsViews(Recycler recycler) {
// Remove items that are out of bounds at the head of the list
while (getChildCount() > 0) {
View child = getChildAt(0);
float centerX = getDecoratedCenterXWithMargins(child);
KeylineRange range =
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, true);
if (isLocOffsetOutOfFillBoundsStart(centerX, range)) {
removeAndRecycleView(child, recycler);
} else {
break;
}
}
// Remove items that are out of bounds at the tail of the list
while (getChildCount() - 1 >= 0) {
View child = getChildAt(getChildCount() - 1);
float centerX = getDecoratedCenterXWithMargins(child);
KeylineRange range =
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, true);
if (isLocOffsetOutOfFillBoundsEnd(centerX, range)) {
removeAndRecycleView(child, recycler);
} else {
break;
}
}
}
/**
* Finds the keylines located immediately before and after {@code location}, forming a keyline
* range that {@code location} is currently within.
*
* <p>When looking before {@code location}, the nearest keyline with the lowest index is found.
* When looking after {@code location}, the nearest keyline with the highest index is found. This
* avoids conflicts if two keylines share the same location and allows keylines to be pinned
* together.
*
* <p>If no keyline is found for the left, the left-most keyline is returned. If no keyline to the
* right is found, the right-most keyline is returned. This means the {@code location} is outside
* the bounds of the outer-most keylines.
*
* @param location The location along the scrolling axis that should be contained by the returned
* keyline range. This can be either a location in the end-to-end model ({@link Keyline#loc}
* or in the offset model {@link Keyline#locOffset}.
* @param isOffset true if {@code location} has been offset and should be compared against {@link
* Keyline#locOffset}, false if {@code location} should be compared against {@link
* Keyline#loc}.
* @return A pair whose first item is the nearest {@link Keyline} before centerX and whose second
* item is the nearest {@link Keyline} after centerX.
*/
private static KeylineRange getSurroundingKeylineRange(
List<Keyline> keylines, float location, boolean isOffset) {
int leftMinDistanceIndex = -1;
float leftMinDistance = Float.MAX_VALUE;
int leftMostIndex = -1;
float leftMostX = Float.MAX_VALUE;
int rightMinDistanceIndex = -1;
float rightMinDistance = Float.MAX_VALUE;
int rightMostIndex = -1;
float rightMostX = -Float.MAX_VALUE;
for (int i = 0; i < keylines.size(); i++) {
Keyline keyline = keylines.get(i);
float currentLoc = isOffset ? keyline.locOffset : keyline.loc;
float delta = abs(currentLoc - location);
// Find the keyline closest to the left of centerX with the lowest index.
if (currentLoc <= location) {
if (delta <= leftMinDistance) {
leftMinDistance = delta;
leftMinDistanceIndex = i;
}
}
// The keyline is to the right of centerX
// Find the keyline closest to the right of centerX with the greatest index.
if (currentLoc > location && delta <= rightMinDistance) {
rightMinDistance = delta;
rightMinDistanceIndex = i;
}
// Find the left-most keyline
if (currentLoc <= leftMostX) {
leftMostIndex = i;
leftMostX = currentLoc;
}
// Find the right-most keyline
if (currentLoc > rightMostX) {
rightMostIndex = i;
rightMostX = currentLoc;
}
}
// If a keyline to the left or right hasn't been found, centerX is outside the bounds of the
// outer-most keylines. Use the outer-most keyline instead.
if (leftMinDistanceIndex == -1) {
leftMinDistanceIndex = leftMostIndex;
}
if (rightMinDistanceIndex == -1) {
rightMinDistanceIndex = rightMostIndex;
}
return new KeylineRange(
keylines.get(leftMinDistanceIndex), keylines.get(rightMinDistanceIndex));
}
/**
* Update the current keyline state by shifting it in response to any change in scroll offset.
*
* <p>This method should be called any time a change in the scroll offset occurs.
*/
private void updateCurrentKeylineStateForScrollOffset() {
this.currentKeylineState =
keylineStateList.getShiftedState(
horizontalScrollOffset, minHorizontalScroll, maxHorizontalScroll);
debugItemDecoration.setKeylines(currentKeylineState.getKeylines());
}
/**
* Calculates the horizontal distance children should be scrolled by for a given {@code dx}.
*
* @param dx the delta, resulting from a gesture or other event, that the list would like to be
* scrolled by
* @param currentHorizontalScroll the current horizontal scroll offset that is always between the
* min and max horizontal scroll
* @param minHorizontalScroll the minimum scroll offset allowed
* @param maxHorizontalScroll the maximum scroll offset allowed
* @return an int that represents the change that should be applied to the current scroll offset,
* given limitations by the min and max scroll offset values
*/
private static int calculateShouldHorizontallyScrollBy(
int dx, int currentHorizontalScroll, int minHorizontalScroll, int maxHorizontalScroll) {
int targetHorizontalScroll = currentHorizontalScroll + dx;
if (targetHorizontalScroll < minHorizontalScroll) {
return minHorizontalScroll - currentHorizontalScroll;
} else if (targetHorizontalScroll > maxHorizontalScroll) {
return maxHorizontalScroll - currentHorizontalScroll;
} else {
return dx;
}
}
/**
* Calculates the total offset needed to scroll the first item in the list to the center of the
* start focal keyline.
*/
private int calculateStartHorizontalScroll(KeylineStateList stateList) {
boolean isRtl = isLayoutRtl();
KeylineState startState = isRtl ? stateList.getRightState() : stateList.getLeftState();
Keyline startFocalKeyline =
isRtl ? startState.getLastFocalKeyline() : startState.getFirstFocalKeyline();
float firstItemDistanceFromStart = getContainerPaddingStart() * (isRtl ? 1 : -1);
float firstItemStart =
addStart((int) startFocalKeyline.loc, (int) (startState.getItemSize() / 2F));
return (int) (firstItemDistanceFromStart + getParentStart() - firstItemStart);
}
/**
* Calculates the total offset needed to scroll the last item in the list to the center of the end
* focal keyline.
*/
private int calculateEndHorizontalScroll(State state, KeylineStateList stateList) {
boolean isRtl = isLayoutRtl();
KeylineState endState = isRtl ? stateList.getLeftState() : stateList.getRightState();
Keyline endFocalKeyline =
isRtl ? endState.getFirstFocalKeyline() : endState.getLastFocalKeyline();
// Get the total distance from the first item to the last item in the end-to-end model
float lastItemDistanceFromFirstItem =
(((state.getItemCount() - 1) * endState.getItemSize()) + getPaddingEnd())
* (isRtl ? -1F : 1F);
// We want the last item in the list to only be able to scroll to the end of the list. Subtract
// the distance to the end focal keyline and then add the distance needed to let the last
// item hit the center of the end focal keyline.
float endFocalLocDistanceFromStart = endFocalKeyline.loc - getParentStart();
float endFocalLocDistanceFromEnd = getParentEnd() - endFocalKeyline.loc;
return (int)
(lastItemDistanceFromFirstItem - endFocalLocDistanceFromStart + endFocalLocDistanceFromEnd);
}
/**
* Calculates the start of the child view item at {@code startPosition} in the end-to-end layout
* model.
*
* <p>This is used to calculate an initial point along the scroll axis from which to start looping
* over adapter items and calculating where children should be placed.
*
* @param startPosition the adapter position of the item whose start position will be calculated
* @return the start location of the view at {@code startPosition} along the scroll axis
*/
private int calculateChildStartForFill(int startPosition) {
float scrollOffset = getParentStart() - horizontalScrollOffset;
float positionOffset = currentKeylineState.getItemSize() * startPosition;
return addEnd((int) scrollOffset, (int) positionOffset);
}
/**
* Remaps and returns the child's offset center from the end-to-end layout model.
*
* @param child the child to calculate the offset for
* @param childCenterLocation the center of the child in the end-to-end layout model
* @param range the keyline range that the child is currently between
* @return the location along the scroll axis where the child should be located
*/
private float calculateChildOffsetCenterForLocation(
View child, float childCenterLocation, KeylineRange range) {
float offsetCx =
lerp(
range.left.locOffset,
range.right.locOffset,
range.left.loc,
range.right.loc,
childCenterLocation);
// If the current centerX is "out of bounds", meaning it is before the first keyline or after
// the last keyline, this item should begin scrolling at a fixed rate according to the
// last keyline it passed (either the first or last keyline).
// Compare reference equality here since there might be multiple keylines with the same
// values as the first/last keyline but we want to ensure this conditional is true only when
// we're working with the same object instance.
if (range.right == currentKeylineState.getFirstKeyline()
|| range.left == currentKeylineState.getLastKeyline()) {
// Calculate how far past the nearest keyline (either the first or last keyline) this item
// has scrolled in the end-to-end layout. Then use that value calculate what would be a
// Keyline#locOffset.
LayoutParams lp = (LayoutParams) child.getLayoutParams();
float horizontalMarginMask =
(lp.rightMargin + lp.leftMargin) / currentKeylineState.getItemSize();
float outOfBoundOffset =
(childCenterLocation - range.right.loc) * (1F - range.right.mask + horizontalMarginMask);
offsetCx += outOfBoundOffset;
}
return offsetCx;
}
/**
* Gets the masked size of a child when its center is at {@code locOffset} and is between the
* given {@code range}.
*
* @param locOffset the offset location along the scrolling axis that should be within the keyline
* {@code range}
* @param range the keyline range that surrounds {@code locOffset}
* @return the masked size of a child when its center is at {@code locOffset} and is between the
* given {@code range}
*/
private float getMaskedItemSizeForLocOffset(float locOffset, KeylineRange range) {
return lerp(
range.left.maskedItemSize,
range.right.maskedItemSize,
range.left.locOffset,
range.right.locOffset,
locOffset);
}
/**
* Calculates and sets the child's mask according to its current location.
*
* @param child the child to mask
* @param childCenterLocation the center of the child in the end-to-end layout model
* @param range the keyline range that the child is currently between
*/
private void updateChildMaskForLocation(
View child, float childCenterLocation, KeylineRange range) {
if (child instanceof Maskable) {
// Interpolate the mask value based on the location of this view between it's two
// surrounding keylines.
float maskProgress =
lerp(
range.left.mask,
range.right.mask,
range.left.loc,
range.right.loc,
childCenterLocation);
((Maskable) child).setMaskXPercentage(maskProgress);
}
}
@Override
public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
LayoutParams lp = (LayoutParams) child.getLayoutParams();
Rect insets = new Rect();
calculateItemDecorationsForChild(child, insets);
widthUsed += insets.left + insets.right;
heightUsed += insets.top + insets.bottom;
// If the configuration's keyline set is available, use the item size from the keyline set.
// Otherwise, measure the item to what it would like to be so the configuration will be given an
// opportunity to use this desired size in making it's sizing decision.
final float childWidthDimension =
keylineStateList != null ? keylineStateList.getDefaultState().getItemSize() : lp.width;
final int widthSpec =
getChildMeasureSpec(
getWidth(),
getWidthMode(),
getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin + widthUsed,
(int) childWidthDimension,
canScrollHorizontally());
final int heightSpec =
getChildMeasureSpec(
getHeight(),
getHeightMode(),
getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin + heightUsed,
lp.height,
canScrollVertically());
child.measure(widthSpec, heightSpec);
}
private int getParentStart() {
return isLayoutRtl() ? getWidth() : 0;
}
private int getParentEnd() {
return isLayoutRtl() ? 0 : getWidth();
}
private int getParentTop() {
return getPaddingTop();
}
private int getParentBottom() {
return getHeight() - getPaddingBottom();
}
@Override
public int getContainerWidth() {
return getWidth();
}
@Override
public int getContainerPaddingStart() {
return getPaddingStart();
}
@Override
public int getContainerPaddingTop() {
return getPaddingTop();
}
@Override
public int getContainerPaddingEnd() {
return getPaddingEnd();
}
@Override
public int getContainerPaddingBottom() {
return getPaddingBottom();
}
private boolean isLayoutRtl() {
return getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL;
}
/** Moves {@code value} towards the start of the container by {@code amount}. */
private int addStart(int value, int amount) {
return isLayoutRtl() ? value + amount : value - amount;
}
/** Moves {@code value} towards the end of the container by {@code amount}. */
private int addEnd(int value, int amount) {
return isLayoutRtl() ? value - amount : value + amount;
}
@Override
public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
if (getChildCount() > 0) {
event.setFromIndex(getPosition(getChildAt(0)));
event.setToIndex(getPosition(getChildAt(getChildCount() - 1)));
}
}
/**
* Gets the scroll offset for a position in the adapter.
*
* <p>This will calculate the horizontal scroll offset needed to place a child at {@code
* position}'s center at the start-most focal keyline. The returned value might be less or greater
* than the min and max scroll offsets but this will be clamped in {@link #scrollBy(int, Recycler,
* State)} (Recycler, State)} by {@link #calculateShouldHorizontallyScrollBy(int, int, int, int)}.
*/
private int getScrollOffsetForPosition(KeylineState keylineState, int position) {
if (isLayoutRtl()) {
return (int)
((getContainerWidth() - keylineState.getLastFocalKeyline().loc)
- (position * keylineState.getItemSize())
- (keylineState.getItemSize() / 2F));
} else {
return (int)
((position * keylineState.getItemSize())
- keylineState.getFirstFocalKeyline().loc
+ (keylineState.getItemSize() / 2F));
}
}
@Override
public void scrollToPosition(int position) {
if (keylineStateList == null) {
return;
}
horizontalScrollOffset =
getScrollOffsetForPosition(keylineStateList.getDefaultState(), position);
currentFillStartPosition = MathUtils.clamp(position, 0, max(0, getItemCount() - 1));
updateCurrentKeylineStateForScrollOffset();
requestLayout();
}
@Override
public boolean canScrollHorizontally() {
return true;
}
@Override
public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {
return canScrollHorizontally() ? scrollBy(dx, recycler, state) : 0;
}
@Override
public boolean requestChildRectangleOnScreen(
@NonNull RecyclerView parent,
@NonNull View child,
@NonNull Rect rect,
boolean immediate,
boolean focusedChildVisible) {
if (keylineStateList == null) {
return false;
}
int offsetForChild =
getScrollOffsetForPosition(keylineStateList.getDefaultState(), getPosition(child));
int dx = offsetForChild - horizontalScrollOffset;
if (!focusedChildVisible) {
if (dx != 0) {
// TODO(b/266816148): Implement smoothScrollBy when immediate is false.
parent.scrollBy(dx, 0);
return true;
}
}
return false;
}
/**
* Offset child items, respecting min and max scroll offsets, and fill additional space with new
* items.
*
* @param distance the total scroll delta requested
* @param recycler current recycler that is attached to the {@link RecyclerView}
* @param state state passed by the {@link RecyclerView} with useful information like item count
* and focal state*
* @return the actually delta scrolled by the list. This will differ from {@code distance} if the
* start or end of the list has been reached.
*/
private int scrollBy(int distance, Recycler recycler, State state) {
if (getChildCount() == 0 || distance == 0) {
return 0;
}
// Calculate how much the carousel should scroll and update the horizontal scroll offset.
int scrolledBy =
calculateShouldHorizontallyScrollBy(
distance, horizontalScrollOffset, minHorizontalScroll, maxHorizontalScroll);
horizontalScrollOffset += scrolledBy;
updateCurrentKeylineStateForScrollOffset();
float halfItemSize = currentKeylineState.getItemSize() / 2F;
int startPosition = getPosition(getChildAt(0));
int start = calculateChildStartForFill(startPosition);
Rect boundsRect = new Rect();
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
offsetChildLeftAndRight(child, start, halfItemSize, boundsRect);
start = addEnd(start, (int) currentKeylineState.getItemSize());
}
// Fill any additional space caused by scrolling with more items.
fill(recycler, state);
return scrolledBy;
}
/**
* Offsets a child horizontally from its current location to its location when its start is placed
* at {@code startOffset} and updates the child's mask according to its new surrounding keylines.
*
* @param child the child to offset
* @param startOffset where the start of the child should be placed, in the end-to-end model,
* after the child has been offset
* @param halfItemSize half of the fully unmasked item size
* @param boundsRect a Rect to use to find the current bounds of {@code child}
*/
private void offsetChildLeftAndRight(
View child, float startOffset, float halfItemSize, Rect boundsRect) {
float centerX = addEnd((int) startOffset, (int) halfItemSize);
KeylineRange range =
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, false);
float offsetCx = calculateChildOffsetCenterForLocation(child, centerX, range);
updateChildMaskForLocation(child, centerX, range);
// Offset the child so its center is at offsetCx
super.getDecoratedBoundsWithMargins(child, boundsRect);
float actualCx = boundsRect.left + halfItemSize;
child.offsetLeftAndRight((int) (offsetCx - actualCx));
}
/**
* Calculate the offset of the horizontal scrollbar thumb within the horizontal range. This is the
* position of the thumb within the scrollbar track.
*
* <p>This is also used for accessibility when scrolling to give auditory feedback about the
* current scroll position within the total range.
*
* <p>This method can return an arbitrary unit as long as the unit is shared across {@link
* #computeHorizontalScrollExtent(State)} and {@link #computeHorizontalScrollRange(State)}.
*/
@Override
public int computeHorizontalScrollOffset(@NonNull State state) {
return horizontalScrollOffset;
}
/**
* Compute the extent of the horizontal scrollbar thumb. This is the size of the thumb inside the
* scrollbar track.
*
* <p>This method can return an arbitrary unit as long as the unit is shared across {@link
* #computeHorizontalScrollExtent(State)} and {@link #computeHorizontalScrollOffset(State)}.
*/
@Override
public int computeHorizontalScrollExtent(@NonNull State state) {
return (int) keylineStateList.getDefaultState().getItemSize();
}
/**
* Compute the horizontal range represented by the horizontal scroll bars. This is the total
* length of the scrollbar track within the range.
*
* <p>This method can return an arbitrary unit as long as the unit is shared across {@link
* #computeHorizontalScrollExtent(State)} and {@link #computeHorizontalScrollOffset(State)}.
*/
@Override
public int computeHorizontalScrollRange(@NonNull State state) {
return maxHorizontalScroll - minHorizontalScroll;
}
/**
* Enables drawing that illustrates keylines and other internal concepts to help debug
* configurations.
*
* @param recyclerView The {@link RecyclerView} this layout manager is attached to.
* @param enabled Whether to draw debug lines.
* @hide
*/
@RestrictTo(Scope.LIBRARY_GROUP)
public void setDrawDebugEnabled(@NonNull RecyclerView recyclerView, boolean enabled) {
recyclerView.removeItemDecoration(debugItemDecoration);
if (enabled) {
recyclerView.addItemDecoration(debugItemDecoration);
}
recyclerView.invalidateItemDecorations();
}
/** A class that represents a pair of keylines which create a range along the scrolling axis. */
private static class KeylineRange {
final Keyline left;
final Keyline right;
/**
* Create a new keyline range.
*
* @param left The left keyline boundary of this range.
* @param right The right keyline boundary of this range.
*/
KeylineRange(Keyline left, Keyline right) {
Preconditions.checkArgument(left.loc <= right.loc);
this.left = left;
this.right = right;
}
}
/**
* A {@link RecyclerView.ItemDecoration} that draws keylines and other information to help debug
* configurations.
*/
private static class DebugItemDecoration extends RecyclerView.ItemDecoration {
private final Paint linePaint = new Paint();
private List<Keyline> keylines = Collections.unmodifiableList(new ArrayList<>());
DebugItemDecoration() {
linePaint.setStrokeWidth(5F);
linePaint.setColor(Color.MAGENTA);
}
/** Updates the keylines that should be drawn over the children in the RecyclerView. */
void setKeylines(List<Keyline> keylines) {
this.keylines = Collections.unmodifiableList(keylines);
}
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
super.onDrawOver(c, parent, state);
linePaint.setStrokeWidth(
parent.getResources().getDimension(R.dimen.m3_carousel_debug_keyline_width));
for (Keyline keyline : keylines) {
linePaint.setColor(ColorUtils.blendARGB(Color.MAGENTA, Color.BLUE, keyline.mask));
c.drawLine(
keyline.locOffset,
((CarouselLayoutManager) parent.getLayoutManager()).getParentTop(),
keyline.locOffset,
((CarouselLayoutManager) parent.getLayoutManager()).getParentBottom(),
linePaint);
}
}
}
}