/* * 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 androidx.recyclerview.widget.RecyclerView.NO_POSITION; import static com.google.android.material.animation.AnimationUtils.lerp; import static java.lang.Math.abs; import static java.lang.Math.max; import static java.lang.Math.min; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.RectF; import androidx.recyclerview.widget.LinearSmoothScroller; 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.util.Log; import android.view.View; import android.view.View.OnLayoutChangeListener; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.annotation.RestrictTo.Scope; import androidx.annotation.VisibleForTesting; 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.CarouselStrategy.StrategyType; import com.google.android.material.carousel.KeylineState.Keyline; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; /** * A {@link LayoutManager} that can mask and offset items along the scrolling axis, creating a * unique list optimized for a stylized viewing experience. * *

{@link CarouselLayoutManager} requires all children to use {@link MaskableFrameLayout} as * their root ViewGroup. * *

Note that when Carousel measures and lays out items, the first item in the adapter will be * measured and it's desired size will be used to determine an appropriate size for all items in the * carousel. * *

For more information, see the component * developer guidance and design * guidelines. */ public class CarouselLayoutManager extends LayoutManager implements Carousel, RecyclerView.SmoothScroller.ScrollVectorProvider { private static final String TAG = "CarouselLayoutManager"; @VisibleForTesting int scrollOffset; // 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. @VisibleForTesting int minScroll; // 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. @VisibleForTesting int maxScroll; private boolean isDebuggingEnabled = false; private final DebugItemDecoration debugItemDecoration = new DebugItemDecoration(); @NonNull private CarouselStrategy carouselStrategy; @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; // Tracks the keyline state associated with each item in the RecyclerView. @Nullable private Map keylineStatePositionMap; /** Horizontal orientation for Carousel. */ public static final int HORIZONTAL = RecyclerView.HORIZONTAL; /** Vertical orientation for Carousel. */ public static final int VERTICAL = RecyclerView.VERTICAL; private CarouselOrientationHelper orientationHelper; private final OnLayoutChangeListener recyclerViewSizeChangeListener = (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { // If RV dimen values have changed, refresh the keyline state. if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { v.post(this::refreshKeylineState); } }; /** Aligns large items to the start of the carousel. */ public static final int ALIGNMENT_START = 0; /** Aligns large items to the center of the carousel. */ public static final int ALIGNMENT_CENTER = 1; private int lastItemCount; /** * An estimation of the current focused position, determined by which item center is closest to * the first focal keyline. This is used when restoring item position after the carousel keylines * are re-calculated due to configuration or size changes. */ private int currentEstimatedPosition = NO_POSITION; /** * Determines where to align the large items in the carousel. * * @hide */ @IntDef({ALIGNMENT_START, ALIGNMENT_CENTER}) @Retention(RetentionPolicy.SOURCE) @interface Alignment {} @Alignment private int carouselAlignment = ALIGNMENT_START; /** * 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 { final View child; final float center; final float offsetCenter; final KeylineRange range; /** * Creates new calculations object. * * @param child The child being calculated for * @param center the location of the center of the {@code child} along the scrolling axis in the * end-to-end model * @param offsetCenter the offset location of the center of the {@code child} along the * scrolling axis where this child will be laid out * @param range the keyline range that surrounds {@code locOffset} */ ChildCalculations(View child, float center, float offsetCenter, KeylineRange range) { this.child = child; this.center = center; this.offsetCenter = offsetCenter; this.range = range; } } public CarouselLayoutManager() { this(new MultiBrowseCarouselStrategy()); } public CarouselLayoutManager(@NonNull CarouselStrategy strategy) { this(strategy, HORIZONTAL); } public CarouselLayoutManager( @NonNull CarouselStrategy strategy, @RecyclerView.Orientation int orientation) { setCarouselStrategy(strategy); setOrientation(orientation); } @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly public CarouselLayoutManager( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { setCarouselStrategy(new MultiBrowseCarouselStrategy()); setCarouselAttributes(context, attrs); } private void setCarouselAttributes(Context context, AttributeSet attrs) { if (attrs != null) { TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Carousel); setCarouselAlignment(a.getInt(R.styleable.Carousel_carousel_alignment, ALIGNMENT_START)); setOrientation(a.getInt(R.styleable.RecyclerView_android_orientation, HORIZONTAL)); a.recycle(); } } /** * Sets the alignment of the focal items in the carousel. */ public void setCarouselAlignment(@Alignment int alignment) { this.carouselAlignment = alignment; refreshKeylineState(); } @Override public int getCarouselAlignment() { return carouselAlignment; } private int getLeftOrTopPaddingForKeylineShift() { // TODO(b/316969331): Fix keyline shifting by decreasing carousel size when carousel is clipped // to padding. if (getClipToPadding()) { return 0; } if (getOrientation() == VERTICAL) { return getPaddingTop(); } return getPaddingLeft(); } private int getRightOrBottomPaddingForKeylineShift() { // TODO(b/316969331): Fix keyline shifting by decreasing carousel size when carousel is clipped // to padding. if (getClipToPadding()) { return 0; } if (getOrientation() == VERTICAL) { return getPaddingBottom(); } return getPaddingRight(); } @Override public LayoutParams generateDefaultLayoutParams() { return new LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); } /** * Sets the {@link CarouselStrategy} used by this layout manager to mask and offset child views as * they move along the scrolling axis. */ public void setCarouselStrategy(@NonNull CarouselStrategy carouselStrategy) { this.carouselStrategy = carouselStrategy; refreshKeylineState(); } @Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); carouselStrategy.initialize(view.getContext()); refreshKeylineState(); view.addOnLayoutChangeListener(recyclerViewSizeChangeListener); } @Override public void onDetachedFromWindow(RecyclerView view, Recycler recycler) { super.onDetachedFromWindow(view, recycler); view.removeOnLayoutChangeListener(recyclerViewSizeChangeListener); } @Override public void onLayoutChildren(Recycler recycler, State state) { if (state.getItemCount() <= 0 || getContainerSize() <= 0f) { removeAndRecycleAllViews(recycler); currentFillStartPosition = 0; return; } boolean isRtl = isLayoutRtl(); boolean isInitialLoad = keylineStateList == null; // 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 strategy to create a keyline state. if (isInitialLoad) { recalculateKeylineStateList(recycler); } // Ensure our scroll limits are initialized and valid for the data set size. int startScroll = calculateStartScroll(keylineStateList); int endScroll = calculateEndScroll(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. minScroll = isRtl ? endScroll : startScroll; maxScroll = isRtl ? startScroll : endScroll; if (isInitialLoad) { // Scroll to the start of the list on first load. scrollOffset = startScroll; keylineStatePositionMap = keylineStateList.getKeylineStateForPositionMap( getItemCount(), minScroll, maxScroll, isLayoutRtl()); if (currentEstimatedPosition != NO_POSITION) { scrollOffset = getScrollOffsetForPosition( currentEstimatedPosition, getKeylineStateForPosition(currentEstimatedPosition)); } } // Clamp the horizontal scroll offset by the new min and max by pinging the scroll by // calculator with a 0 delta. scrollOffset += calculateShouldScrollBy(0, scrollOffset, minScroll, maxScroll); // Ensure currentFillStartPosition is valid if the number of items in the adapter has changed. currentFillStartPosition = MathUtils.clamp(currentFillStartPosition, 0, state.getItemCount()); updateCurrentKeylineStateForScrollOffset(keylineStateList); detachAndScrapAttachedViews(recycler); fill(recycler, state); lastItemCount = getItemCount(); } @Override public boolean isAutoMeasureEnabled() { return true; } private void recalculateKeylineStateList(Recycler recycler) { View firstChild = recycler.getViewForPosition(0); measureChildWithMargins(firstChild, 0, 0); KeylineState keylineState = carouselStrategy.onFirstChildMeasuredWithMargins(this, firstChild); keylineStateList = KeylineStateList.from( this, isLayoutRtl() ? KeylineState.reverse(keylineState, getContainerSize()) : keylineState, getItemMargins(), getLeftOrTopPaddingForKeylineShift(), getRightOrBottomPaddingForKeylineShift(), carouselStrategy.getStrategyType()); } private int getItemMargins() { if (getChildCount() > 0) { LayoutParams lp = (LayoutParams) getChildAt(0).getLayoutParams(); if (orientationHelper.orientation == HORIZONTAL) { return lp.leftMargin + lp.rightMargin; } return lp.topMargin + lp.bottomMargin; } return 0; } /** * Recalculates the {@link KeylineState} and {@link KeylineStateList} for the current strategy. */ private void refreshKeylineState() { keylineStateList = null; requestLayout(); } /** * Adds and places children into the {@link RecyclerView}, handling child layout and recycling * according to this class' {@link CarouselStrategy}. * *

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)}. * *

This layout manager tracks item location using two "models". The first is an end-to-end * model that keeps track of items as if they were laid out one after the other and fully unmasked * (the same way they would be laid out in a traditional list). This model is primarily useful for * tracking scroll minimums, maximums, and offsets. The second model is an offset model which is * the location of an item after it's position has been interpolated from {@link Keyline#loc} * (it's end-to-end location) to {@link Keyline#locOffset}. This is the model in which children * are actually laid out and drawn. * * @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); } validateChildOrderIfDebugging(); } @Override public void onLayoutCompleted(State state) { super.onLayoutCompleted(state); if (getChildCount() == 0) { currentFillStartPosition = 0; } else { currentFillStartPosition = getPosition(getChildAt(0)); } validateChildOrderIfDebugging(); } /** * 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) { float start = calculateChildStartForFill(startPosition); for (int i = startPosition; i >= 0; i--) { ChildCalculations calculations = makeChildCalculations(recycler, start, i); if (isLocOffsetOutOfFillBoundsStart(calculations.offsetCenter, calculations.range)) { break; } start = addStart(start, 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.offsetCenter, calculations.range)) { continue; } // Add this child to the first index of the RecyclerView. addAndLayoutView(calculations.child, /* index= */ 0, calculations); } } /** * Adds a child view to the RecyclerView at the given {@code childIndex}, regardless of whether or * not the view is in bounds. * * @param recycler current recycler that is attached to the {@link RecyclerView} * @param startPosition the position of the adapter whose view is to be added * @param childIndex the index of the RecyclerView's children that the view should be added at */ private void addViewAtPosition(@NonNull Recycler recycler, int startPosition, int childIndex) { if (startPosition < 0 || startPosition >= getItemCount()) { return; } float start = calculateChildStartForFill(startPosition); ChildCalculations calculations = makeChildCalculations(recycler, start, startPosition); // Add this child to the given child index of the RecyclerView. addAndLayoutView(calculations.child, /* index= */ childIndex, calculations); } /** * 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) { float start = calculateChildStartForFill(startPosition); for (int i = startPosition; i < state.getItemCount(); i++) { ChildCalculations calculations = makeChildCalculations(recycler, start, i); if (isLocOffsetOutOfFillBoundsEnd(calculations.offsetCenter, calculations.range)) { break; } start = addEnd(start, 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.offsetCenter, calculations.range)) { continue; } // Add this child to the last index of the RecyclerView addAndLayoutView(calculations.child, /* index= */ -1, calculations); } } /** Used for debugging. Logs the internal representation of children to default logger. */ private void logChildrenIfDebugging() { if (!isDebuggingEnabled) { return; } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "internal representation of views on the screen"); for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); float center = getDecoratedCenterWithMargins(child); Log.d( TAG, "item position " + getPosition(child) + ", center:" + center + ", child index:" + i); } Log.d(TAG, "=============="); } } /** * Used for debugging. Validates that child views are laid out in correct order. This is important * because rest of the algorithm relies on this constraint. * *

Child 0 should be closest to adapter position 0 and last child should be closest to the last * adapter position. */ private void validateChildOrderIfDebugging() { if (!isDebuggingEnabled || getChildCount() < 1) { return; } for (int i = 0; i < getChildCount() - 1; i++) { int currPos = getPosition(getChildAt(i)); int nextPos = getPosition(getChildAt(i + 1)); if (currPos > nextPos) { logChildrenIfDebugging(); throw new IllegalStateException( "Detected invalid child order. Child at index [" + i + "] had adapter position [" + currPos + "] and child at index [" + (i + 1) + "] had adapter position [" + nextPos + "]."); } } } /** * Calculates position and mask for a view at at adapter {@code position} and returns an object * with the calculated values. * *

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) { View child = recycler.getViewForPosition(position); measureChildWithMargins(child, 0, 0); float center = addEnd(start, currentKeylineState.getItemSize() / 2F); KeylineRange range = getSurroundingKeylineRange(currentKeylineState.getKeylines(), center, false); float offsetCenter = calculateChildOffsetCenterForLocation(child, center, range); return new ChildCalculations(child, center, offsetCenter, 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 index the index at which to add the child to the RecyclerView. Use 0 for adding to the * start of the list and -1 for adding to the end. * @param calculations the child calculations to be used to layout this view */ private void addAndLayoutView(View child, int index, ChildCalculations calculations) { float halfItemSize = currentKeylineState.getItemSize() / 2F; addView(child, index); int start = (int) (calculations.offsetCenter - halfItemSize); int end = (int) (calculations.offsetCenter + halfItemSize); orientationHelper.layoutDecoratedWithMargins(child, start, end); updateChildMaskForLocation(child, calculations.center, calculations.range); } /** * 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}. * *

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); float maskedEnd = addEnd(locOffset, maskedSize / 2F); return isLayoutRtl() ? maskedEnd > getContainerSize() : maskedEnd < 0; } @Override public boolean isHorizontal() { return orientationHelper.orientation == HORIZONTAL; } /** * 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}. * *

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); float maskedStart = addStart(locOffset, maskedSize / 2F); return isLayoutRtl() ? maskedStart < 0 : maskedStart > getContainerSize(); } /** * Returns the masked, decorated bounds with margins for {@code view}. * *

Note that this differs from the super method which returns the fully unmasked bounds of * {@code view}. * *

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 center = outBounds.centerY(); if (isHorizontal()) { center = outBounds.centerX(); } float maskedSize = getMaskedItemSizeForLocOffset( center, getSurroundingKeylineRange(currentKeylineState.getKeylines(), center, true)); float deltaX = isHorizontal() ? (outBounds.width() - maskedSize) / 2F : 0; float deltaY = isHorizontal() ? 0 : (outBounds.height() - maskedSize) / 2F; outBounds.set( (int) (outBounds.left + deltaX), (int) (outBounds.top + deltaY), (int) (outBounds.right - deltaX), (int) (outBounds.bottom - deltaY)); } private float getDecoratedCenterWithMargins(View child) { Rect bounds = new Rect(); super.getDecoratedBoundsWithMargins(child, bounds); if (isHorizontal()) { return bounds.centerX(); } return bounds.centerY(); } /** * Remove and recycle any views outside of the bounds of this carousel. * *

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 center = getDecoratedCenterWithMargins(child); KeylineRange range = getSurroundingKeylineRange(currentKeylineState.getKeylines(), center, true); if (isLocOffsetOutOfFillBoundsStart(center, 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 center = getDecoratedCenterWithMargins(child); KeylineRange range = getSurroundingKeylineRange(currentKeylineState.getKeylines(), center, true); if (isLocOffsetOutOfFillBoundsEnd(center, 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. * *

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. * *

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. If the orientation is vertical, the same * goes for top-most and bottom-most keylines respectively. 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 center and whose second * item is the nearest {@link Keyline} after center. */ private static KeylineRange getSurroundingKeylineRange( List keylines, float location, boolean isOffset) { int startMinDistanceIndex = -1; float startMinDistance = Float.MAX_VALUE; int startMostIndex = -1; float startMostX = Float.MAX_VALUE; int endMinDistanceIndex = -1; float endMinDistance = Float.MAX_VALUE; int endMostIndex = -1; float endMostX = -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 center with the lowest index. if (currentLoc <= location) { if (delta <= startMinDistance) { startMinDistance = delta; startMinDistanceIndex = i; } } // The keyline is to the right of center // Find the keyline closest to the right of center with the greatest index. if (currentLoc > location && delta <= endMinDistance) { endMinDistance = delta; endMinDistanceIndex = i; } // Find the left-most keyline if (currentLoc <= startMostX) { startMostIndex = i; startMostX = currentLoc; } // Find the right-most keyline if (currentLoc > endMostX) { endMostIndex = i; endMostX = currentLoc; } } // If a keyline to the left or right hasn't been found, center is outside the bounds of the // outer-most keylines. Use the outer-most keyline instead. if (startMinDistanceIndex == -1) { startMinDistanceIndex = startMostIndex; } if (endMinDistanceIndex == -1) { endMinDistanceIndex = endMostIndex; } return new KeylineRange( keylines.get(startMinDistanceIndex), keylines.get(endMinDistanceIndex)); } /** * Update the current keyline state by shifting it in response to any change in scroll offset. * *

This method should be called any time a change in the scroll offset occurs. */ private void updateCurrentKeylineStateForScrollOffset( @NonNull KeylineStateList keylineStateList) { if (maxScroll <= minScroll) { // We don't have enough items in the list to scroll and we should use the keyline state // that aligns items to the start of the container. this.currentKeylineState = isLayoutRtl() ? keylineStateList.getEndState() : keylineStateList.getStartState(); } else { this.currentKeylineState = keylineStateList.getShiftedState(scrollOffset, minScroll, maxScroll); } debugItemDecoration.setKeylines(currentKeylineState.getKeylines()); } /** * Calculates the distance children should be scrolled by for a given {@code delta}. * * @param delta the delta, resulting from a gesture or other event, that the list would like to be * scrolled by * @param currentScroll the current horizontal scroll offset that is always between the min and * max horizontal scroll * @param minScroll the minimum scroll offset allowed * @param maxScroll 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 calculateShouldScrollBy( int delta, int currentScroll, int minScroll, int maxScroll) { int targetScroll = currentScroll + delta; if (targetScroll < minScroll) { return minScroll - currentScroll; } else if (targetScroll > maxScroll) { return maxScroll - currentScroll; } else { return delta; } } /** * Calculates the total offset needed to scroll the first item in the list to the center of the * start focal keyline. */ private int calculateStartScroll(@NonNull KeylineStateList stateList) { boolean isRtl = isLayoutRtl(); KeylineState startState = isRtl ? stateList.getEndState() : stateList.getStartState(); Keyline startFocalKeyline = isRtl ? startState.getLastFocalKeyline() : startState.getFirstFocalKeyline(); float firstItemStart = addStart(startFocalKeyline.loc, startState.getItemSize() / 2F); // This value already includes any padding since startFocalKeyline.loc is already adjusted return (int) (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 calculateEndScroll(State state, KeylineStateList stateList) { boolean isRtl = isLayoutRtl(); KeylineState endState = isRtl ? stateList.getStartState() : stateList.getEndState(); 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()) * (isRtl ? -1F : 1F); float endFocalLocDistanceFromStart = endFocalKeyline.loc - getParentStart(); // 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. int endScroll = (int) (lastItemDistanceFromFirstItem - endFocalLocDistanceFromStart + (isRtl ? -1 : 1) * endFocalKeyline.maskedItemSize / 2F); return isRtl ? min(0, endScroll) : max(0, endScroll); } /** * Calculates the start of the child view item at {@code startPosition} in the end-to-end layout * model. * *

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 float calculateChildStartForFill(int startPosition) { float childScrollOffset = getParentStart() - scrollOffset; float positionOffset = currentKeylineState.getItemSize() * startPosition; return addEnd(childScrollOffset, 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 offsetCenter = lerp( range.leftOrTop.locOffset, range.rightOrBottom.locOffset, range.leftOrTop.loc, range.rightOrBottom.loc, childCenterLocation); // If the current center 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.rightOrBottom == currentKeylineState.getFirstKeyline() || range.leftOrTop == 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 marginMask = orientationHelper.getMaskMargins(lp) / currentKeylineState.getItemSize(); float outOfBoundOffset = (childCenterLocation - range.rightOrBottom.loc) * (1F - range.rightOrBottom.mask + marginMask); offsetCenter += outOfBoundOffset; } return offsetCenter; } /** * 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.leftOrTop.maskedItemSize, range.rightOrBottom.maskedItemSize, range.leftOrTop.locOffset, range.rightOrBottom.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)) { return; } // Interpolate the mask value based on the location of this view between it's two // surrounding keylines. float maskProgress = lerp( range.leftOrTop.mask, range.rightOrBottom.mask, range.leftOrTop.loc, range.rightOrBottom.loc, childCenterLocation); float childHeight = child.getHeight(); float childWidth = child.getWidth(); // Translate the percentage into an actual pixel value of how much of this view should be // masked away. float maskWidth = lerp(0F, childWidth / 2F, 0F, 1F, maskProgress); float maskHeight = lerp(0F, childHeight / 2F, 0F, 1F, maskProgress); RectF maskRect = orientationHelper.getMaskRect(childHeight, childWidth, maskHeight, maskWidth); float offsetCenter = calculateChildOffsetCenterForLocation(child, childCenterLocation, range); float maskedTop = offsetCenter - (maskRect.height() / 2F); float maskedBottom = offsetCenter + (maskRect.height() / 2F); float maskedLeft = offsetCenter - (maskRect.width() / 2F); float maskedRight = offsetCenter + (maskRect.width() / 2F); RectF offsetMaskRect = new RectF(maskedLeft, maskedTop, maskedRight, maskedBottom); RectF parentBoundsRect = new RectF(getParentLeft(), getParentTop(), getParentRight(), getParentBottom()); // If the carousel is a CONTAINED carousel, ensure the mask collapses against the side of the // container instead of bleeding and being clipped by the RecyclerView's bounds. // Only do this if there is only one side of the mask that is out of bounds; if // both sides are out of bounds on the same side, then the whole mask is out of view. if (carouselStrategy.getStrategyType() == StrategyType.CONTAINED) { orientationHelper.containMaskWithinBounds(maskRect, offsetMaskRect, parentBoundsRect); } // 'Push out' any masks that are on the parent edge by rounding up/down and adding or // subtracting a pixel. Otherwise, the mask on the 'edge' looks like it has a width of 1 pixel. orientationHelper.moveMaskOnEdgeOutsideBounds(maskRect, offsetMaskRect, parentBoundsRect); ((Maskable) child).setMaskRectF(maskRect); } @Override public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) { if (!(child instanceof Maskable)) { throw new IllegalStateException( "All children of a RecyclerView using CarouselLayoutManager must use MaskableFrameLayout" + " as their root ViewGroup."); } LayoutParams lp = (LayoutParams) child.getLayoutParams(); Rect insets = new Rect(); calculateItemDecorationsForChild(child, insets); widthUsed += insets.left + insets.right; heightUsed += insets.top + insets.bottom; // If the strategy'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 strategy will be given an // opportunity to use this desired size in making it's sizing decision. final float childWidthDimension = keylineStateList != null && orientationHelper.orientation == HORIZONTAL ? keylineStateList.getDefaultState().getItemSize() : lp.width; final float childHeightDimension = keylineStateList != null && orientationHelper.orientation == VERTICAL ? keylineStateList.getDefaultState().getItemSize() : lp.height; 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, (int) childHeightDimension, canScrollVertically()); child.measure(widthSpec, heightSpec); } private int getParentLeft() { return orientationHelper.getParentLeft(); } private int getParentStart() { return orientationHelper.getParentStart(); } private int getParentRight() { return orientationHelper.getParentRight(); } private int getParentTop() { return orientationHelper.getParentTop(); } private int getParentBottom() { return orientationHelper.getParentBottom(); } @Override public int getContainerWidth() { return getWidth(); } @Override public int getContainerHeight() { return getHeight(); } /** * Returns the dimension of the container according to the carousel orientation. Eg. width if the * orientation is horizontal, height if vertical. */ private int getContainerSize() { if (isHorizontal()) { return getContainerWidth(); } return getContainerHeight(); } boolean isLayoutRtl() { return isHorizontal() && getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL; } /** Moves {@code value} towards the start of the container by {@code amount}. */ private float addStart(float value, float amount) { return isLayoutRtl() ? value + amount : value - amount; } /** Moves {@code value} towards the end of the container by {@code amount}. */ private float addEnd(float value, float 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. * *

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)}. * * @param position The position to get the scroll offset to. * @param keylineState The keyline state in which to calculate the scroll offset to. */ private int getScrollOffsetForPosition(int position, KeylineState keylineState) { if (isLayoutRtl()) { return (int) ((getContainerSize() - keylineState.getLastFocalKeyline().loc) - (position * keylineState.getItemSize()) - (keylineState.getItemSize() / 2F)); } else { return (int) ((position * keylineState.getItemSize()) - keylineState.getFirstFocalKeyline().loc + (keylineState.getItemSize() / 2F)); } } private int getSmallestScrollOffsetToFocalKeyline( int position, @NonNull KeylineState keylineState) { int smallestScrollOffset = Integer.MAX_VALUE; for (Keyline keyline : keylineState.getFocalKeylines()) { float offsetWithoutKeylines = position * keylineState.getItemSize(); float halfFocalKeylineSize = keylineState.getItemSize() / 2F; float offsetWithKeylines = offsetWithoutKeylines + halfFocalKeylineSize; int positionOffsetDistanceFromKeyline = isLayoutRtl() ? (int) ((getContainerSize() - keyline.loc) - offsetWithKeylines) : (int) (offsetWithKeylines - keyline.loc); positionOffsetDistanceFromKeyline -= scrollOffset; if (Math.abs(smallestScrollOffset) > Math.abs(positionOffsetDistanceFromKeyline)) { smallestScrollOffset = positionOffsetDistanceFromKeyline; } } return smallestScrollOffset; } @Nullable @Override public PointF computeScrollVectorForPosition(int targetPosition) { if (keylineStateList == null) { return null; } KeylineState keylineForScroll = getKeylineStateForPosition(targetPosition); int offset = getOffsetToScrollToPosition(targetPosition, keylineForScroll); if (isHorizontal()) { return new PointF(offset, 0F); } return new PointF(0F, offset); } /** * Gets the offset needed to scroll to a position from the current scroll offset. * *

This will calculate the horizontal scroll offset needed to place a child at {@code * position}'s center at the start-most focal keyline. */ int getOffsetToScrollToPosition(int position, @NonNull KeylineState keylineState) { int targetScrollOffset = getScrollOffsetForPosition(position, keylineState); return targetScrollOffset - scrollOffset; } /** * Gets the offset needed to snap to a position from the current scroll offset. * *

This will calculate the horizontal scroll offset needed to place a child at {@code * position}'s center at the start-most focal keyline of the target keyline state to snap to. * *

Sometimes we may want to do a partial snap. Eg. When there is a fling event, the snap * distance is fetched before it finishes scrolling and the target keyline state is not yet * updated. Once the fling event finishes scrolling, the snap is triggered again with the correct * target keyline state. If {@code partialSnap} is true, then we want to snap to whichever is * smaller between {@code targetKeylineStateForSnap}, which is the closest keyline state step to * the current keyline state, or the KeylineState at the correct position in {@code * keylineStatePositionList}. Note that if there is any distance left to be snapped when the * fling-scroll stops, the snap helper will handle it. */ int getOffsetToScrollToPositionForSnap(int position, boolean partialSnap) { KeylineState targetKeylineStateForSnap = keylineStateList.getShiftedState(scrollOffset, minScroll, maxScroll, true); int targetSnapOffset = getOffsetToScrollToPosition(position, targetKeylineStateForSnap); int positionOffset = targetSnapOffset; if (keylineStatePositionMap != null) { positionOffset = getOffsetToScrollToPosition(position, getKeylineStateForPosition(position)); } if (partialSnap) { return Math.abs(positionOffset) < Math.abs(targetSnapOffset) ? positionOffset : targetSnapOffset; } return targetSnapOffset; } private KeylineState getKeylineStateForPosition(int position) { if (keylineStatePositionMap != null) { KeylineState keylineState = keylineStatePositionMap.get( MathUtils.clamp(position, 0, max(0, getItemCount() - 1))); if (keylineState != null) { return keylineState; } } return keylineStateList.getDefaultState(); } @Override public void scrollToPosition(int position) { currentEstimatedPosition = position; if (keylineStateList == null) { return; } scrollOffset = getScrollOffsetForPosition(position, getKeylineStateForPosition(position)); currentFillStartPosition = MathUtils.clamp(position, 0, max(0, getItemCount() - 1)); updateCurrentKeylineStateForScrollOffset(keylineStateList); requestLayout(); } @Override public void smoothScrollToPosition(RecyclerView recyclerView, State state, int position) { LinearSmoothScroller linearSmoothScroller = new LinearSmoothScroller(recyclerView.getContext()) { @Nullable @Override public PointF computeScrollVectorForPosition(int targetPosition) { return CarouselLayoutManager.this.computeScrollVectorForPosition(targetPosition); } @Override public int calculateDxToMakeVisible(View view, int snapPreference) { if (keylineStateList == null || !isHorizontal()) { return 0; } // Override dx calculations so the target view is brought all the way into the focal // range instead of just being made visible. return calculateScrollDeltaToMakePositionVisible(getPosition(view)); } @Override public int calculateDyToMakeVisible(View view, int snapPreference) { if (keylineStateList == null || isHorizontal()) { return 0; } // Override dy calculations so the target view is brought all the way into the focal // range instead of just being made visible. return calculateScrollDeltaToMakePositionVisible(getPosition(view)); } }; linearSmoothScroller.setTargetPosition(position); startSmoothScroll(linearSmoothScroller); } int calculateScrollDeltaToMakePositionVisible(int position) { KeylineState scrollToKeyline = getKeylineStateForPosition(position); float targetScrollOffset = getScrollOffsetForPosition(position, scrollToKeyline); return (int) (scrollOffset - targetScrollOffset); } @Override public boolean canScrollHorizontally() { return isHorizontal(); } @Override public int scrollHorizontallyBy(int dx, Recycler recycler, State state) { return canScrollHorizontally() ? scrollBy(dx, recycler, state) : 0; } @Override public boolean canScrollVertically() { return !isHorizontal(); } @Override public int scrollVerticallyBy(int dy, Recycler recycler, State state) { return canScrollVertically() ? scrollBy(dy, recycler, state) : 0; } /** * Helper class to encapsulate information about the layout direction in relation to the focus * direction. */ private static class LayoutDirection { private static final int LAYOUT_START = -1; private static final int LAYOUT_END = 1; private static final int INVALID_LAYOUT = Integer.MIN_VALUE; } /** * Converts a focusDirection to a layout direction. * * @param focusDirection One of {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN}, {@link * View#FOCUS_LEFT}, {@link View#FOCUS_RIGHT}, {@link View#FOCUS_BACKWARD}, {@link * View#FOCUS_FORWARD} or 0 for not applicable * @return {@link LayoutDirection#LAYOUT_START} or {@link LayoutDirection#LAYOUT_END} if focus * direction is applicable to current state, {@link LayoutDirection#INVALID_LAYOUT} otherwise. */ private int convertFocusDirectionToLayoutDirection(int focusDirection) { int orientation = getOrientation(); switch (focusDirection) { case View.FOCUS_BACKWARD: return LayoutDirection.LAYOUT_START; case View.FOCUS_FORWARD: return LayoutDirection.LAYOUT_END; case View.FOCUS_UP: return orientation == VERTICAL ? LayoutDirection.LAYOUT_START : LayoutDirection.INVALID_LAYOUT; case View.FOCUS_DOWN: return orientation == VERTICAL ? LayoutDirection.LAYOUT_END : LayoutDirection.INVALID_LAYOUT; case View.FOCUS_LEFT: if (orientation == HORIZONTAL) { return isLayoutRtl() ? LayoutDirection.LAYOUT_END : LayoutDirection.LAYOUT_START; } return LayoutDirection.INVALID_LAYOUT; case View.FOCUS_RIGHT: if (orientation == HORIZONTAL) { return isLayoutRtl() ? LayoutDirection.LAYOUT_START : LayoutDirection.LAYOUT_END; } return LayoutDirection.INVALID_LAYOUT; default: Log.d(TAG, "Unknown focus request:" + focusDirection); return LayoutDirection.INVALID_LAYOUT; } } @Nullable @Override public View onFocusSearchFailed( @NonNull View focused, int focusDirection, @NonNull Recycler recycler, @NonNull State state) { if (getChildCount() == 0) { return null; } final int layoutDir = convertFocusDirectionToLayoutDirection(focusDirection); if (layoutDir == LayoutDirection.INVALID_LAYOUT) { return null; } final View nextFocus; if (layoutDir == LayoutDirection.LAYOUT_START) { if (getPosition(focused) == 0) { return null; } int firstPosition = getPosition(getChildAt(0)); addViewAtPosition(recycler, firstPosition - 1, 0); nextFocus = getChildClosestToStart(); } else { if (getPosition(focused) == getItemCount() - 1) { return null; } int lastPosition = getPosition(getChildAt(getChildCount() - 1)); addViewAtPosition(recycler, lastPosition + 1, -1); nextFocus = getChildClosestToEnd(); } return nextFocus; } /** * Convenience method to find the child closes to start. Caller should check if it has enough * children. * * @return The child closest to start of the layout from user's perspective. */ private View getChildClosestToStart() { return getChildAt(isLayoutRtl() ? getChildCount() - 1 : 0); } /** * Convenience method to find the child closes to end. Caller should check if it has enough * children. * * @return The child closest to end of the layout from user's perspective. */ private View getChildClosestToEnd() { return getChildAt(isLayoutRtl() ? 0 : getChildCount() - 1); } @Override public boolean requestChildRectangleOnScreen( @NonNull RecyclerView parent, @NonNull View child, @NonNull Rect rect, boolean immediate, boolean focusedChildVisible) { if (keylineStateList == null) { return false; } int delta = getSmallestScrollOffsetToFocalKeyline( getPosition(child), getKeylineStateForPosition(getPosition(child))); // TODO(b/266816148): Implement smoothScrollBy when immediate is false. if (delta == 0) { return false; } // Get the keyline state at the scroll offset, and scroll based on that. int realDelta = calculateShouldScrollBy(delta, scrollOffset, minScroll, maxScroll); KeylineState scrolledKeylineState = keylineStateList.getShiftedState(scrollOffset + realDelta, minScroll, maxScroll); delta = getSmallestScrollOffsetToFocalKeyline(getPosition(child), scrolledKeylineState); scrollBy(parent, delta); return true; } private void scrollBy(RecyclerView recyclerView, int delta) { if (isHorizontal()) { recyclerView.scrollBy(delta, 0); } else { recyclerView.scrollBy(0, delta); } } /** * 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; } if (keylineStateList == null) { recalculateKeylineStateList(recycler); } // Calculate how much the carousel should scroll and update the scroll offset. int scrolledBy = calculateShouldScrollBy(distance, scrollOffset, minScroll, maxScroll); scrollOffset += scrolledBy; updateCurrentKeylineStateForScrollOffset(keylineStateList); float halfItemSize = currentKeylineState.getItemSize() / 2F; int startPosition = getPosition(getChildAt(0)); float start = calculateChildStartForFill(startPosition); Rect boundsRect = new Rect(); float firstFocalKeylineLoc = isLayoutRtl() ? currentKeylineState.getLastFocalKeyline().locOffset : currentKeylineState.getFirstFocalKeyline().locOffset; float absDistanceToFirstFocal = Float.MAX_VALUE; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); float offsetCenter = offsetChild(child, start, halfItemSize, boundsRect); float distanceToFirstFocal = Math.abs(firstFocalKeylineLoc - offsetCenter); if (child != null && distanceToFirstFocal < absDistanceToFirstFocal) { absDistanceToFirstFocal = distanceToFirstFocal; currentEstimatedPosition = getPosition(child); } start = addEnd(start, currentKeylineState.getItemSize()); } // Fill any additional space caused by scrolling with more items. fill(recycler, state); return scrolledBy; } /** * Offsets a child 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} * @return the center in which the child is offset to */ private float offsetChild(View child, float startOffset, float halfItemSize, Rect boundsRect) { float center = addEnd(startOffset, halfItemSize); KeylineRange range = getSurroundingKeylineRange(currentKeylineState.getKeylines(), center, false); float offsetCenter = calculateChildOffsetCenterForLocation(child, center, range); // Offset the child so its center is at offsetCenter super.getDecoratedBoundsWithMargins(child, boundsRect); updateChildMaskForLocation(child, center, range); orientationHelper.offsetChild(child, boundsRect, halfItemSize, offsetCenter); return offsetCenter; } /** * Calculate the offset of the horizontal scrollbar thumb within the horizontal range. This is the * position of the thumb within the scrollbar track. * *

This is also used for accessibility when scrolling to give auditory feedback about the * current scroll position within the total range. * *

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 scrollOffset; } /** * Compute the extent of the horizontal scrollbar thumb. This is the size of the thumb inside the * scrollbar track. * *

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) { if (getChildCount() == 0 || keylineStateList == null || getItemCount() <= 1) { return 0; } float itemRatio = (keylineStateList.getDefaultState().getItemSize() / computeHorizontalScrollRange(state)); return (int) (getWidth() * itemRatio); } /** * Compute the horizontal range represented by the horizontal scroll bars. This is the total * length of the scrollbar track within the range. * *

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 maxScroll - minScroll; } @Override public int computeVerticalScrollOffset(@NonNull State state) { return scrollOffset; } @Override public int computeVerticalScrollExtent(@NonNull State state) { if (getChildCount() == 0 || keylineStateList == null || getItemCount() <= 1) { return 0; } float itemRatio = (keylineStateList.getDefaultState().getItemSize() / computeVerticalScrollRange(state)); return (int) (getHeight() * itemRatio); } @Override public int computeVerticalScrollRange(@NonNull State state) { return maxScroll - minScroll; } /** * Returns the current orientation of the layout. * * @return Current orientation, either {@link #HORIZONTAL} or {@link #VERTICAL} * @see #setOrientation(int) */ @RecyclerView.Orientation public int getOrientation() { return orientationHelper.orientation; } /** * Sets the orientation of the layout. * * @param orientation {@link #HORIZONTAL} or {@link #VERTICAL} */ public void setOrientation(@RecyclerView.Orientation int orientation) { if (orientation != HORIZONTAL && orientation != VERTICAL) { throw new IllegalArgumentException("invalid orientation:" + orientation); } assertNotInLayoutOrScroll(null); if (orientationHelper == null || orientation != orientationHelper.orientation) { orientationHelper = CarouselOrientationHelper.createOrientationHelper(this, orientation); refreshKeylineState(); } } @Override public void onItemsAdded(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { super.onItemsAdded(recyclerView, positionStart, itemCount); updateItemCount(); } @Override public void onItemsRemoved(@NonNull RecyclerView recyclerView, int positionStart, int itemCount) { super.onItemsRemoved(recyclerView, positionStart, itemCount); updateItemCount(); } private void updateItemCount() { int newItemCount = getItemCount(); if (newItemCount == this.lastItemCount || keylineStateList == null) { return; } if (carouselStrategy.shouldRefreshKeylineState(this, this.lastItemCount)) { refreshKeylineState(); } this.lastItemCount = newItemCount; } /** * Enables features to help debug keylines and other internal layout manager logic. * *

This will draw lines on top of the RecyclerView that show where keylines are placed for the * current {@link CarouselStrategy}. Enabling debugging will also throw an exception when an * invalid child order is detected (child index and adapter position are incorrectly ordered). See * {@link #validateChildOrderIfDebugging()} ()} ()} for more details. * * @param recyclerView The {@link RecyclerView} this layout manager is attached to. * @param enabled Whether to draw debug lines and throw on state errors. * @hide */ @RestrictTo(Scope.LIBRARY_GROUP) public void setDebuggingEnabled(@NonNull RecyclerView recyclerView, boolean enabled) { this.isDebuggingEnabled = 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 leftOrTop; final Keyline rightOrBottom; /** * Create a new keyline range. * * @param leftOrTop The start keyline boundary of this range. * @param rightOrBottom The end keyline boundary of this range. */ KeylineRange(Keyline leftOrTop, Keyline rightOrBottom) { Preconditions.checkArgument(leftOrTop.loc <= rightOrBottom.loc); this.leftOrTop = leftOrTop; this.rightOrBottom = rightOrBottom; } } /** * A {@link RecyclerView.ItemDecoration} that draws keylines and other information to help debug * strategies. */ private static class DebugItemDecoration extends RecyclerView.ItemDecoration { private final Paint linePaint = new Paint(); private List 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 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)); if (((CarouselLayoutManager) parent.getLayoutManager()).isHorizontal()) { c.drawLine( keyline.locOffset, ((CarouselLayoutManager) parent.getLayoutManager()).getParentTop(), keyline.locOffset, ((CarouselLayoutManager) parent.getLayoutManager()).getParentBottom(), linePaint); } else { c.drawLine( ((CarouselLayoutManager) parent.getLayoutManager()).getParentLeft(), keyline.locOffset, ((CarouselLayoutManager) parent.getLayoutManager()).getParentRight(), keyline.locOffset, linePaint); } } } } }