blob: 1ddc8eb39d9cb9ae39e4a6a1e72565d5ecd57af2 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.view;
import android.content.Context;
import android.graphics.PointF;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.v7.widget.LinearSmoothScroller;
import android.support.v7.widget.RecyclerView;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import com.android.car.stream.ui.R;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
/**
* Custom {@link RecyclerView.LayoutManager} that behaves similar to LinearLayoutManager except that
* it has a few tricks up its sleeve.
* <ol>
* <li>In a normal ListView, when views reach the top of the list, they are clipped. In
* CarLayoutManager, views have the option of flying off of the top of the screen as the
* next row settles in to place. This functionality can be enabled or disabled with
* {@link #setOffsetRows(boolean)}.
* <li>Standard list physics is disabled. Instead, when the user scrolls, it will settle
* on the next page. {@link #FLING_THRESHOLD_TO_PAGINATE} and
* {@link #DRAG_DISTANCE_TO_PAGINATE} can be set to have the list settle on the next item
* instead of the next page for small gestures.
* <li>Items can scroll past the bottom edge of the screen. This helps with pagination so that
* the last page can be properly aligned.
* </ol>
*
* This LayoutManger should be used with {@link CarRecyclerView}.
*/
public class CarLayoutManager extends RecyclerView.LayoutManager {
private static final String TAG = "CarLayoutManager";
private static final boolean DEBUG = false;
/**
* Any fling below the threshold will just scroll to the top fully visible row. The units is
* whatever {@link android.widget.Scroller} would return.
*
* A reasonable value is ~200
*
* This can be disabled by setting the threshold to -1.
*/
private static final int FLING_THRESHOLD_TO_PAGINATE = -1;
/**
* Any fling shorter than this threshold (in px) will just scroll to the top fully visible row.
*
* A reasonable value is 15.
*
* This can be disabled by setting the distance to -1.
*/
private static final int DRAG_DISTANCE_TO_PAGINATE = -1;
/**
* If you scroll really quickly, you can hit the end of the laid out rows before Android has a
* chance to layout more. To help counter this, we can layout a number of extra rows past
* wherever the focus is if necessary.
*/
private static final int NUM_EXTRA_ROWS_TO_LAYOUT_PAST_FOCUS = 2;
/**
* Scroll bar calculation is a bit complicated. This basically defines the granularity we want
* our scroll bar to move. Set this to 1 means our scrollbar will have really jerky movement.
* Setting it too big will risk an overflow (although there is no performance impact). Ideally
* we want to set this higher than the height of our list view. We can't use our list view
* height directly though because we might run into situations where getHeight() returns 0, for
* example, when the view is not yet measured.
*/
private static final int SCROLL_RANGE = 1000;
@ScrollStyle private final int SCROLL_TYPE = MARIO;
@Retention(RetentionPolicy.SOURCE)
@IntDef({MARIO, SUPER_MARIO})
private @interface ScrollStyle {}
private static final int MARIO = 0;
private static final int SUPER_MARIO = 1;
@Retention(RetentionPolicy.SOURCE)
@IntDef({BEFORE, AFTER})
private @interface LayoutDirection {}
private static final int BEFORE = 0;
private static final int AFTER = 1;
@Retention(RetentionPolicy.SOURCE)
@IntDef({ROW_OFFSET_MODE_INDIVIDUAL, ROW_OFFSET_MODE_PAGE})
public @interface RowOffsetMode {}
public static final int ROW_OFFSET_MODE_INDIVIDUAL = 0;
public static final int ROW_OFFSET_MODE_PAGE = 1;
public interface OnItemsChangedListener {
void onItemsChanged();
}
private final AccelerateInterpolator mDanglingRowInterpolator = new AccelerateInterpolator(2);
private final Context mContext;
/** Determines whether or not rows will be offset as they slide off screen **/
private boolean mOffsetRows = false;
/** Determines whether rows will be offset individually or a page at a time **/
@RowOffsetMode private int mRowOffsetMode = ROW_OFFSET_MODE_PAGE;
/**
* The LayoutManager only gets {@link #onScrollStateChanged(int)} updates. This enables the
* scroll state to be used anywhere.
*/
private int mScrollState = RecyclerView.SCROLL_STATE_IDLE;
/**
* Used to inspect the current scroll state to help with the various calculations.
**/
private CarSmoothScroller mSmoothScroller;
private OnItemsChangedListener mItemsChangedListener;
/** The distance that the list has actually scrolled in the most recent drag gesture **/
private int mLastDragDistance = 0;
/** True if the current drag was limited/capped because it was at some boundary **/
private boolean mReachedLimitOfDrag;
/**
* The values are continuously updated to keep track of where the current page boundaries are
* on screen. The anchor page break is the page break that is currently within or at the
* top of the viewport. The Upper page break is the page break before it and the lower page
* break is the page break after it.
*
* A page break will be set to -1 if it is unknown or n/a.
* @see #updatePageBreakPositions()
*/
private int mItemCountDuringLastPageBreakUpdate;
// The index of the first item on the current page
private int mAnchorPageBreakPosition = 0;
// The index of the first item on the previous page
private int mUpperPageBreakPosition = -1;
// The index of the first item on the next page
private int mLowerPageBreakPosition = -1;
/** Used in the bookkeeping of mario style scrolling to prevent extra calculations. **/
private int mLastChildPositionToRequestFocus = -1;
private int mSampleViewHeight = -1;
/**
* Set the anchor to the following position on the next layout pass.
*/
private int mPendingScrollPosition = -1;
public CarLayoutManager(Context context) {
mContext = context;
}
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
@Override
public boolean canScrollVertically() {
return true;
}
/**
* onLayoutChildren is sort of like a "reset" for the layout state. At a high level, it should:
* <ol>
* <li>Check the current views to get the current state of affairs
* <li>Detach all views from the window (a lightweight operation) so that rows
* not re-added will be removed after onLayoutChildren.
* <li>Re-add rows as necessary.
* </ol>
*
* @see super#onLayoutChildren(RecyclerView.Recycler, RecyclerView.State)
*/
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
/**
* The anchor view is the first fully visible view on screen at the beginning
* of onLayoutChildren (or 0 if there is none). This row will be laid out first. After that,
* layoutNextRow will layout rows above and below it until the boundaries of what should
* be laid out have been reached. See {@link #shouldLayoutNextRow(View, int)} for
* more information.
*/
int anchorPosition = 0;
int anchorTop = -1;
if (mPendingScrollPosition == -1) {
View anchor = getFirstFullyVisibleChild();
if (anchor != null) {
anchorPosition = getPosition(anchor);
anchorTop = getDecoratedTop(anchor);
}
} else {
anchorPosition = mPendingScrollPosition;
mPendingScrollPosition = -1;
mAnchorPageBreakPosition = anchorPosition;
mUpperPageBreakPosition = -1;
mLowerPageBreakPosition = -1;
}
if (DEBUG) {
Log.v(TAG, String.format(
":: onLayoutChildren anchorPosition:%s, anchorTop:%s,"
+ " mPendingScrollPosition: %s, mAnchorPageBreakPosition:%s,"
+ " mUpperPageBreakPosition:%s, mLowerPageBreakPosition:%s",
anchorPosition, anchorTop, mPendingScrollPosition, mAnchorPageBreakPosition,
mUpperPageBreakPosition, mLowerPageBreakPosition));
}
/**
* Detach all attached view for 2 reasons:
* <ol>
* <li> So that views are put in the scrap heap. This enables us to call
* {@link RecyclerView.Recycler#getViewForPosition(int)} which will either return
* one of these detached views if it is in the scrap heap, one from the
* recycled pool (will only call onBind in the adapter), or create an entirely new
* row if needed (will call onCreate and onBind in the adapter).
* <li> So that views are automatically removed if they are not manually re-added.
* </ol>
*/
detachAndScrapAttachedViews(recycler);
// Layout new rows.
View anchor = layoutAnchor(recycler, anchorPosition, anchorTop);
if (anchor != null) {
View adjacentRow = anchor;
while (shouldLayoutNextRow(state, adjacentRow, BEFORE)) {
adjacentRow = layoutNextRow(recycler, adjacentRow, BEFORE);
}
adjacentRow = anchor;
while (shouldLayoutNextRow(state, adjacentRow, AFTER)) {
adjacentRow = layoutNextRow(recycler, adjacentRow, AFTER);
}
}
updatePageBreakPositions();
offsetRows();
if (DEBUG&& getChildCount() > 1) {
Log.v(TAG, "Currently showing " + getChildCount() + " views " +
getPosition(getChildAt(0)) + " to " +
getPosition(getChildAt(getChildCount() - 1)) + " anchor " + anchorPosition);
}
}
/**
* scrollVerticallyBy does the work of what should happen when the list scrolls in addition
* to handling cases where the list hits the end. It should be lighter weight than
* onLayoutChildren. It doesn't have to detach all views. It only looks at the end of the list
* and removes views that have gone out of bounds and lays out new ones that scroll in.
*
* @param dy The amount that the list is supposed to scroll.
* > 0 means the list is scrolling down.
* < 0 means the list is scrolling up.
* @param recycler The recycler that enables views to be reused or created as they scroll in.
* @param state Various information about the current state of affairs.
* @return The amount the list actually scrolled.
*
* @see super#scrollVerticallyBy(int, RecyclerView.Recycler, RecyclerView.State)
*/
@Override
public int scrollVerticallyBy(
int dy, @NonNull RecyclerView.Recycler recycler, @NonNull RecyclerView.State state) {
// If the list is empty, we can prevent the overscroll glow from showing by just
// telling RecycerView that we scrolled.
if (getItemCount() == 0) {
return dy;
}
// Prevent redundant computations if there is definitely nowhere to scroll to.
if (getChildCount() <= 1 || dy == 0) {
return 0;
}
View firstChild = getChildAt(0);
if (firstChild == null) {
return 0;
}
int firstChildPosition = getPosition(firstChild);
RecyclerView.LayoutParams firstChildParams = getParams(firstChild);
int firstChildTopWithMargin = getDecoratedTop(firstChild) - firstChildParams.topMargin;
View lastFullyVisibleView = getChildAt(getLastFullyVisibleChildIndex());
if (lastFullyVisibleView == null) {
return 0;
}
boolean isLastViewVisible = getPosition(lastFullyVisibleView) == getItemCount() - 1;
View firstFullyVisibleChild = getFirstFullyVisibleChild();
if (firstFullyVisibleChild == null) {
return 0;
}
int firstFullyVisiblePosition = getPosition(firstFullyVisibleChild);
RecyclerView.LayoutParams firstFullyVisibleChildParams = getParams(firstFullyVisibleChild);
int topRemainingSpace = getDecoratedTop(firstFullyVisibleChild)
- firstFullyVisibleChildParams.topMargin - getPaddingTop();
if (isLastViewVisible && firstFullyVisiblePosition == mAnchorPageBreakPosition
&& dy > topRemainingSpace && dy > 0) {
// Prevent dragging down more than 1 page. As a side effect, this also prevents you
// from dragging past the bottom because if you are on the second to last page, it
// prevents you from dragging past the last page.
dy = topRemainingSpace;
mReachedLimitOfDrag = true;
} else if (dy < 0 && firstChildPosition == 0
&& firstChildTopWithMargin + Math.abs(dy) > getPaddingTop()) {
// Prevent scrolling past the beginning
dy = firstChildTopWithMargin - getPaddingTop();
mReachedLimitOfDrag = true;
} else {
mReachedLimitOfDrag = false;
}
boolean isDragging = mScrollState == RecyclerView.SCROLL_STATE_DRAGGING;
if (isDragging) {
mLastDragDistance += dy;
}
// We offset by -dy because the views translate in the opposite direction that the
// list scrolls (think about it.)
offsetChildrenVertical(-dy);
// The last item in the layout should never scroll above the viewport
View view = getChildAt(getChildCount() - 1);
if (view.getTop() < 0) {
view.setTop(0);
}
// This is the meat of this function. We remove views on the trailing edge of the scroll
// and add views at the leading edge as necessary.
View adjacentRow;
if (dy > 0) {
recycleChildrenFromStart(recycler);
adjacentRow = getChildAt(getChildCount() - 1);
while (shouldLayoutNextRow(state, adjacentRow, AFTER)) {
adjacentRow = layoutNextRow(recycler, adjacentRow, AFTER);
}
} else {
recycleChildrenFromEnd(recycler);
adjacentRow = getChildAt(0);
while (shouldLayoutNextRow(state, adjacentRow, BEFORE)) {
adjacentRow = layoutNextRow(recycler, adjacentRow, BEFORE);
}
}
// Now that the correct views are laid out, offset rows as necessary so we can do whatever
// fancy animation we want such as having the top view fly off the screen as the next one
// settles in to place.
updatePageBreakPositions();
offsetRows();
if (getChildCount() > 1) {
if (DEBUG) {
Log.v(TAG, String.format("Currently showing %d views (%d to %d)",
getChildCount(), getPosition(getChildAt(0)),
getPosition(getChildAt(getChildCount() - 1))));
}
}
return dy;
}
@Override
public void scrollToPosition(int position) {
mPendingScrollPosition = position;
requestLayout();
}
@Override
public void smoothScrollToPosition(
RecyclerView recyclerView, RecyclerView.State state, int position) {
/**
* startSmoothScroll will handle stopping the old one if there is one.
* We only keep a copy of it to handle the translation of rows as they slide off the screen
* in {@link #offsetRowsWithPageBreak()}
*/
mSmoothScroller = new CarSmoothScroller(mContext, position);
mSmoothScroller.setTargetPosition(position);
startSmoothScroll(mSmoothScroller);
}
/**
* Miscellaneous bookkeeping.
*/
@Override
public void onScrollStateChanged(int state) {
if (DEBUG) {
Log.v(TAG, ":: onScrollStateChanged " + state);
}
if (state == RecyclerView.SCROLL_STATE_IDLE) {
// If the focused view is off screen, give focus to one that is.
// If the first fully visible view is first in the list, focus the first item.
// Otherwise, focus the second so that you have the first item as scrolling context.
View focusedChild = getFocusedChild();
if (focusedChild != null
&& (getDecoratedTop(focusedChild) >= getHeight() - getPaddingBottom()
|| getDecoratedBottom(focusedChild) <= getPaddingTop())) {
focusedChild.clearFocus();
requestLayout();
}
} else if (state == RecyclerView.SCROLL_STATE_DRAGGING) {
mLastDragDistance = 0;
}
if (state != RecyclerView.SCROLL_STATE_SETTLING) {
mSmoothScroller = null;
}
mScrollState = state;
updatePageBreakPositions();
}
@Override
public void onItemsChanged(RecyclerView recyclerView) {
super.onItemsChanged(recyclerView);
if (mItemsChangedListener != null) {
mItemsChangedListener.onItemsChanged();
}
// When item changed, our sample view height is no longer accurate, and need to be
// recomputed.
mSampleViewHeight = -1;
}
/**
* Gives us the opportunity to override the order of the focused views.
* By default, it will just go from top to bottom. However, if there is no focused views, we
* take over the logic and start the focused views from the middle of what is visible and move
* from there until the end of the laid out views in the specified direction.
*/
@Override
public boolean onAddFocusables(
RecyclerView recyclerView, ArrayList<View> views, int direction, int focusableMode) {
View focusedChild = getFocusedChild();
if (focusedChild != null) {
// If there is a view that already has focus, we can just return false and the normal
// Android addFocusables will work fine.
return false;
}
// Now we know that there isn't a focused view. We need to set up focusables such that
// instead of just focusing the first item that has been laid out, it focuses starting
// from a visible item.
int firstFullyVisibleChildIndex = getFirstFullyVisibleChildIndex();
if (firstFullyVisibleChildIndex == -1) {
// Somehow there is a focused view but there is no fully visible view. There shouldn't
// be a way for this to happen but we'd better stop here and return instead of
// continuing on with -1.
Log.w(TAG, "There is a focused child but no first fully visible child.");
return false;
}
View firstFullyVisibleChild = getChildAt(firstFullyVisibleChildIndex);
int firstFullyVisibleChildPosition = getPosition(firstFullyVisibleChild);
int firstFocusableChildIndex = firstFullyVisibleChildIndex;
if (firstFullyVisibleChildPosition > 0 && firstFocusableChildIndex + 1 < getItemCount()) {
// We are somewhere in the middle of the list. Instead of starting focus on the first
// item, start focus on the second item to give some context that we aren't at
// the beginning.
firstFocusableChildIndex++;
}
if (direction == View.FOCUS_FORWARD) {
// Iterate from the first focusable view to the end.
for (int i = firstFocusableChildIndex; i < getChildCount(); i++) {
views.add(getChildAt(i));
}
return true;
} else if (direction == View.FOCUS_BACKWARD) {
// Iterate from the first focusable view to the beginning.
for (int i = firstFocusableChildIndex; i >= 0; i--) {
views.add(getChildAt(i));
}
return true;
}
return false;
}
@Override
public View onFocusSearchFailed(View focused, int direction, RecyclerView.Recycler recycler,
RecyclerView.State state) {
return null;
}
/**
* This is the function that decides where to scroll to when a new view is focused.
* You can get the position of the currently focused child through the child parameter.
* Once you have that, determine where to smooth scroll to and scroll there.
*
* @param parent The RecyclerView hosting this LayoutManager
* @param state Current state of RecyclerView
* @param child Direct child of the RecyclerView containing the newly focused view
* @param focused The newly focused view. This may be the same view as child or it may be null
* @return true if the default scroll behavior should be suppressed
*/
@Override
public boolean onRequestChildFocus(RecyclerView parent, RecyclerView.State state,
View child, View focused) {
if (child == null) {
Log.w(TAG, "onRequestChildFocus with a null child!");
return true;
}
if (DEBUG) {
Log.v(TAG, String.format(":: onRequestChildFocus child: %s, focused: %s", child,
focused));
}
// We have several distinct scrolling methods. Each implementation has been delegated
// to its own method.
if (SCROLL_TYPE == MARIO) {
return onRequestChildFocusMarioStyle(parent, child);
} else if (SCROLL_TYPE == SUPER_MARIO) {
return onRequestChildFocusSuperMarioStyle(parent, state, child);
} else {
throw new IllegalStateException("Unknown scroll type (" + SCROLL_TYPE + ")");
}
}
/**
* Goal: the scrollbar maintains the same size throughout scrolling and that the scrollbar
* reaches the bottom of the screen when the last item is fully visible. This is because
* there are multiple points that could be considered the bottom since the last item can scroll
* past the bottom edge of the screen.
*
* To find the extent, we divide the number of items that can fit on screen by the number of
* items in total.
*/
@Override
public int computeVerticalScrollExtent(RecyclerView.State state) {
if (getChildCount() <= 1) {
return 0;
}
int sampleViewHeight = getSampleViewHeight();
int availableHeight = getAvailableHeight();
int sampleViewsThatCanFitOnScreen = availableHeight / sampleViewHeight;
if (state.getItemCount() <= sampleViewsThatCanFitOnScreen) {
return SCROLL_RANGE;
} else {
return SCROLL_RANGE * sampleViewsThatCanFitOnScreen / state.getItemCount();
}
}
/**
* The scrolling offset is calculated by determining what position is at the top of the list.
* However, instead of using fixed integer positions for each row, the scroll position is
* factored in and the position is recalculated as a float that takes in to account the
* current scroll state. This results in a smooth animation for the scrollbar when the user
* scrolls the list.
*/
@Override
public int computeVerticalScrollOffset(RecyclerView.State state) {
View firstChild = getFirstFullyVisibleChild();
if (firstChild == null) {
return 0;
}
RecyclerView.LayoutParams params = getParams(firstChild);
int firstChildPosition = getPosition(firstChild);
// Assume the previous view is the same height as the current one.
float percentOfPreviousViewShowing = (getDecoratedTop(firstChild) - params.topMargin)
/ (float) (getDecoratedMeasuredHeight(firstChild)
+ params.topMargin + params.bottomMargin);
// If the previous view is actually larger than the current one then this the percent
// can be greater than 1.
percentOfPreviousViewShowing = Math.min(percentOfPreviousViewShowing, 1);
float currentPosition = (float) firstChildPosition - percentOfPreviousViewShowing;
int sampleViewHeight = getSampleViewHeight();
int availableHeight = getAvailableHeight();
int numberOfSampleViewsThatCanFitOnScreen = availableHeight / sampleViewHeight;
int positionWhenLastItemIsVisible =
state.getItemCount() - numberOfSampleViewsThatCanFitOnScreen;
if (positionWhenLastItemIsVisible <= 0) {
return 0;
}
if (currentPosition >= positionWhenLastItemIsVisible) {
return SCROLL_RANGE;
}
return (int) (SCROLL_RANGE * currentPosition / positionWhenLastItemIsVisible);
}
/**
* The range of the scrollbar can be understood as the granularity of how we want the
* scrollbar to scroll.
*/
@Override
public int computeVerticalScrollRange(RecyclerView.State state) {
return SCROLL_RANGE;
}
/**
* @return The first view that starts on screen. It assumes that it fully fits on the screen
* though. If the first fully visible child is also taller than the screen then it will
* still be returned. However, since the LayoutManager snaps to view starts, having
* a row that tall would lead to a broken experience anyways.
*/
public int getFirstFullyVisibleChildIndex() {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
RecyclerView.LayoutParams params = getParams(child);
if (getDecoratedTop(child) - params.topMargin >= getPaddingTop()) {
return i;
}
}
return -1;
}
public View getFirstFullyVisibleChild() {
int firstFullyVisibleChildIndex = getFirstFullyVisibleChildIndex();
View firstChild = null;
if (firstFullyVisibleChildIndex != -1) {
firstChild = getChildAt(firstFullyVisibleChildIndex);
}
return firstChild;
}
/**
* @return The last view that ends on screen. It assumes that the start is also on screen
* though. If the last fully visible child is also taller than the screen then it will
* still be returned. However, since the LayoutManager snaps to view starts, having
* a row that tall would lead to a broken experience anyways.
*/
public int getLastFullyVisibleChildIndex() {
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
RecyclerView.LayoutParams params = getParams(child);
int childBottom = getDecoratedBottom(child) + params.bottomMargin;
int listBottom = getHeight() - getPaddingBottom();
if (childBottom <= listBottom) {
return i;
}
}
return -1;
}
/**
* @return Whether or not the first view is fully visible.
*/
public boolean isAtTop() {
// getFirstFullyVisibleChildIndex() can return -1 which indicates that there are no views
// and also means that the list is at the top.
return getFirstFullyVisibleChildIndex() <= 0;
}
/**
* @return Whether or not the last view is fully visible.
*/
public boolean isAtBottom() {
int lastFullyVisibleChildIndex = getLastFullyVisibleChildIndex();
if (lastFullyVisibleChildIndex == -1) {
return true;
}
View lastFullyVisibleChild = getChildAt(lastFullyVisibleChildIndex);
return getPosition(lastFullyVisibleChild) == getItemCount() - 1;
}
public void setOffsetRows(boolean offsetRows) {
mOffsetRows = offsetRows;
if (offsetRows) {
offsetRows();
} else {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
getChildAt(i).setTranslationY(0);
}
}
}
public void setRowOffsetMode(@RowOffsetMode int mode) {
if (mode == mRowOffsetMode) {
return;
}
mRowOffsetMode = mode;
offsetRows();
}
public void setItemsChangedListener(OnItemsChangedListener listener) {
mItemsChangedListener = listener;
}
/**
* Finish the pagination taking into account where the gesture started (not where we are now).
*
* @return Whether the list was scrolled as a result of the fling.
*/
public boolean settleScrollForFling(RecyclerView parent, int flingVelocity) {
if (getChildCount() == 0) {
return false;
}
if (mReachedLimitOfDrag) {
return false;
}
// If the fling was too slow or too short, settle on the first fully visible row instead.
if (Math.abs(flingVelocity) <= FLING_THRESHOLD_TO_PAGINATE
|| Math.abs(mLastDragDistance) <= DRAG_DISTANCE_TO_PAGINATE) {
int firstFullyVisibleChildIndex = getFirstFullyVisibleChildIndex();
if (firstFullyVisibleChildIndex != -1) {
int scrollPosition = getPosition(getChildAt(firstFullyVisibleChildIndex));
parent.smoothScrollToPosition(scrollPosition);
return true;
}
return false;
}
// Finish the pagination taking into account where the gesture
// started (not where we are now).
boolean isDownGesture = flingVelocity > 0
|| (flingVelocity == 0 && mLastDragDistance >= 0);
boolean isUpGesture = flingVelocity < 0
|| (flingVelocity == 0 && mLastDragDistance < 0);
if (isDownGesture && mLowerPageBreakPosition != -1) {
// If the last view is fully visible then only settle on the first fully visible view
// instead of the original page down position. However, don't page down if the last
// item has come fully into view.
parent.smoothScrollToPosition(mAnchorPageBreakPosition);
return true;
} else if (isUpGesture && mUpperPageBreakPosition != -1) {
parent.smoothScrollToPosition(mUpperPageBreakPosition);
return true;
} else {
Log.e(TAG, "Error setting scroll for fling! flingVelocity: \t" + flingVelocity +
"\tlastDragDistance: " + mLastDragDistance + "\tpageUpAtStartOfDrag: " +
mUpperPageBreakPosition + "\tpageDownAtStartOfDrag: " +
mLowerPageBreakPosition);
// As a last resort, at the last smooth scroller target position if there is one.
if (mSmoothScroller != null) {
parent.smoothScrollToPosition(mSmoothScroller.getTargetPosition());
return true;
}
}
return false;
}
/**
* @return The position that paging up from the current position would settle at.
*/
public int getPageUpPosition() {
return mUpperPageBreakPosition;
}
/**
* @return The position that paging down from the current position would settle at.
*/
public int getPageDownPosition() {
return mLowerPageBreakPosition;
}
/**
* Layout the anchor row. The anchor row is the first fully visible row.
*
* @param anchorTop The decorated top of the anchor. If it is not known or should be reset
* to the top, pass -1.
*/
private View layoutAnchor(RecyclerView.Recycler recycler, int anchorPosition, int anchorTop) {
if (anchorPosition > getItemCount() - 1) {
return null;
}
View anchor = recycler.getViewForPosition(anchorPosition);
RecyclerView.LayoutParams params = getParams(anchor);
measureChildWithMargins(anchor, 0, 0);
int left = getPaddingLeft() + params.leftMargin;
int top = (anchorTop == -1) ? params.topMargin : anchorTop;
int right = left + getDecoratedMeasuredWidth(anchor);
int bottom = top + getDecoratedMeasuredHeight(anchor);
layoutDecorated(anchor, left, top, right, bottom);
addView(anchor);
return anchor;
}
/**
* Lays out the next row in the specified direction next to the specified adjacent row.
*
* @param recycler The recycler from which a new view can be created.
* @param adjacentRow The View of the adjacent row which will be used to position the new one.
* @param layoutDirection The side of the adjacent row that the new row will be laid out on.
*
* @return The new row that was laid out.
*/
private View layoutNextRow(RecyclerView.Recycler recycler, View adjacentRow,
@LayoutDirection int layoutDirection) {
int adjacentRowPosition = getPosition(adjacentRow);
int newRowPosition = adjacentRowPosition;
if (layoutDirection == BEFORE) {
newRowPosition = adjacentRowPosition - 1;
} else if (layoutDirection == AFTER) {
newRowPosition = adjacentRowPosition + 1;
}
// Because we detach all rows in onLayoutChildren, this will often just return a view from
// the scrap heap.
View newRow = recycler.getViewForPosition(newRowPosition);
measureChildWithMargins(newRow, 0, 0);
RecyclerView.LayoutParams newRowParams =
(RecyclerView.LayoutParams) newRow.getLayoutParams();
RecyclerView.LayoutParams adjacentRowParams =
(RecyclerView.LayoutParams) adjacentRow.getLayoutParams();
int left = getPaddingLeft() + newRowParams.leftMargin;
int right = left + getDecoratedMeasuredWidth(newRow);
int top, bottom;
if (layoutDirection == BEFORE) {
bottom = adjacentRow.getTop() - adjacentRowParams.topMargin - newRowParams.bottomMargin;
top = bottom - getDecoratedMeasuredHeight(newRow);
} else {
top = getDecoratedBottom(adjacentRow) +
adjacentRowParams.bottomMargin + newRowParams.topMargin;
bottom = top + getDecoratedMeasuredHeight(newRow);
}
layoutDecorated(newRow, left, top, right, bottom);
if (layoutDirection == BEFORE) {
addView(newRow, 0);
} else {
addView(newRow);
}
return newRow;
}
/**
* @return Whether another row should be laid out in the specified direction.
*/
private boolean shouldLayoutNextRow(RecyclerView.State state, View adjacentRow,
@LayoutDirection int layoutDirection) {
int adjacentRowPosition = getPosition(adjacentRow);
if (layoutDirection == BEFORE) {
if (adjacentRowPosition == 0) {
// We already laid out the first row.
return false;
}
} else if (layoutDirection == AFTER) {
if (adjacentRowPosition >= state.getItemCount() - 1) {
// We already laid out the last row.
return false;
}
}
// If we are scrolling layout views until the target position.
if (mSmoothScroller != null) {
if (layoutDirection == BEFORE
&& adjacentRowPosition >= mSmoothScroller.getTargetPosition()) {
return true;
} else if (layoutDirection == AFTER
&& adjacentRowPosition <= mSmoothScroller.getTargetPosition()) {
return true;
}
}
View focusedRow = getFocusedChild();
if (focusedRow != null) {
int focusedRowPosition = getPosition(focusedRow);
if (layoutDirection == BEFORE && adjacentRowPosition
>= focusedRowPosition - NUM_EXTRA_ROWS_TO_LAYOUT_PAST_FOCUS) {
return true;
} else if (layoutDirection == AFTER && adjacentRowPosition
<= focusedRowPosition + NUM_EXTRA_ROWS_TO_LAYOUT_PAST_FOCUS) {
return true;
}
}
RecyclerView.LayoutParams params = getParams(adjacentRow);
int adjacentRowTop = getDecoratedTop(adjacentRow) - params.topMargin;
int adjacentRowBottom = getDecoratedBottom(adjacentRow) - params.bottomMargin;
if (layoutDirection == BEFORE
&& adjacentRowTop < getPaddingTop() - getHeight()) {
// View is more than 1 page past the top of the screen and also past where the user has
// scrolled to. We want to keep one page past the top to make the scroll up calculation
// easier and scrolling smoother.
return false;
} else if (layoutDirection == AFTER
&& adjacentRowBottom > getHeight() - getPaddingBottom()) {
// View is off of the bottom and also past where the user has scrolled to.
return false;
}
return true;
}
/**
* Remove and recycle views that are no longer needed.
*/
private void recycleChildrenFromStart(RecyclerView.Recycler recycler) {
// Start laying out children one page before the top of the viewport.
int childrenStart = getPaddingTop() - getHeight();
int focusedChildPosition = Integer.MAX_VALUE;
View focusedChild = getFocusedChild();
if (focusedChild != null) {
focusedChildPosition = getPosition(focusedChild);
}
// Count the number of views that should be removed.
int detachedCount = 0;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
int childEnd = getDecoratedBottom(child);
int childPosition = getPosition(child);
if (childEnd >= childrenStart || childPosition >= focusedChildPosition - 1) {
break;
}
detachedCount++;
}
// Remove the number of views counted above. Done by removing the first child n times.
while (--detachedCount >= 0) {
final View child = getChildAt(0);
removeAndRecycleView(child, recycler);
}
}
/**
* Remove and recycle views that are no longer needed.
*/
private void recycleChildrenFromEnd(RecyclerView.Recycler recycler) {
// Layout views until the end of the viewport.
int childrenEnd = getHeight();
int focusedChildPosition = Integer.MIN_VALUE + 1;
View focusedChild = getFocusedChild();
if (focusedChild != null) {
focusedChildPosition = getPosition(focusedChild);
}
// Count the number of views that should be removed.
int firstDetachedPos = 0;
int detachedCount = 0;
int childCount = getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View child = getChildAt(i);
int childStart = getDecoratedTop(child);
int childPosition = getPosition(child);
if (childStart <= childrenEnd || childPosition <= focusedChildPosition - 1) {
break;
}
firstDetachedPos = i;
detachedCount++;
}
while (--detachedCount >= 0) {
final View child = getChildAt(firstDetachedPos);
removeAndRecycleView(child, recycler);
}
}
/**
* Offset rows to do fancy animations. If {@link #mOffsetRows} is false, this will do nothing.
*
* @see #offsetRowsIndividually()
* @see #offsetRowsByPage()
*/
public void offsetRows() {
if (!mOffsetRows) {
return;
}
if (mRowOffsetMode == ROW_OFFSET_MODE_PAGE) {
offsetRowsByPage();
} else if (mRowOffsetMode == ROW_OFFSET_MODE_INDIVIDUAL) {
offsetRowsIndividually();
}
}
/**
* Offset the single row that is scrolling off the screen such that by the time the next row
* reaches the top, it will have accelerated completely off of the screen.
*/
private void offsetRowsIndividually() {
if (getChildCount() == 0) {
if (DEBUG) {
Log.d(TAG, ":: offsetRowsIndividually getChildCount=0");
}
return;
}
// Identify the dangling row. It will be the first row that is at the top of the
// list or above.
int danglingChildIndex = -1;
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (getDecoratedTop(child) - getParams(child).topMargin <= getPaddingTop()) {
danglingChildIndex = i;
break;
}
}
mAnchorPageBreakPosition = danglingChildIndex;
if (DEBUG) {
Log.v(TAG, ":: offsetRowsIndividually danglingChildIndex: " + danglingChildIndex);
}
// Calculate the total amount that the view will need to scroll in order to go completely
// off screen.
RecyclerView rv = (RecyclerView) getChildAt(0).getParent();
int[] locs = new int[2];
rv.getLocationInWindow(locs);
int listTopInWindow = locs[1] + rv.getPaddingTop();
int maxDanglingViewTranslation;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
RecyclerView.LayoutParams params = getParams(child);
maxDanglingViewTranslation = listTopInWindow;
// If the child has a negative margin, we'll actually need to translate the view a
// little but further to get it completely off screen.
if (params.topMargin < 0) {
maxDanglingViewTranslation -= params.topMargin;
}
if (params.bottomMargin < 0) {
maxDanglingViewTranslation -= params.bottomMargin;
}
if (i < danglingChildIndex) {
child.setAlpha(0f);
} else if (i > danglingChildIndex) {
child.setAlpha(1f);
child.setTranslationY(0);
} else {
int totalScrollDistance = getDecoratedMeasuredHeight(child) +
params.topMargin + params.bottomMargin;
int distanceLeftInScroll = getDecoratedBottom(child) +
params.bottomMargin - getPaddingTop();
float percentageIntoScroll = 1 - distanceLeftInScroll / (float) totalScrollDistance;
float interpolatedPercentage =
mDanglingRowInterpolator.getInterpolation(percentageIntoScroll);
child.setAlpha(1f);
child.setTranslationY(-(maxDanglingViewTranslation * interpolatedPercentage));
}
}
}
/**
* When the list scrolls, the entire page of rows will offset in one contiguous block. This
* significantly reduces the amount of extra motion at the top of the screen.
*/
private void offsetRowsByPage() {
View anchorView = findViewByPosition(mAnchorPageBreakPosition);
if (anchorView == null) {
if (DEBUG) {
Log.d(TAG, ":: offsetRowsByPage anchorView null");
}
return;
}
int anchorViewTop = getDecoratedTop(anchorView) - getParams(anchorView).topMargin;
View upperPageBreakView = findViewByPosition(mUpperPageBreakPosition);
int upperViewTop = getDecoratedTop(upperPageBreakView)
- getParams(upperPageBreakView).topMargin;
int scrollDistance = upperViewTop - anchorViewTop;
int distanceLeft = anchorViewTop - getPaddingTop();
float scrollPercentage = (Math.abs(scrollDistance) - distanceLeft)
/ (float) Math.abs(scrollDistance);
if (DEBUG) {
Log.d(TAG, String.format(
":: offsetRowsByPage scrollDistance:%s, distanceLeft:%s, scrollPercentage:%s",
scrollDistance, distanceLeft, scrollPercentage));
}
// Calculate the total amount that the view will need to scroll in order to go completely
// off screen.
RecyclerView rv = (RecyclerView) getChildAt(0).getParent();
int[] locs = new int[2];
rv.getLocationInWindow(locs);
int listTopInWindow = locs[1] + rv.getPaddingTop();
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
int position = getPosition(child);
if (position < mUpperPageBreakPosition) {
child.setAlpha(0f);
child.setTranslationY(-listTopInWindow);
} else if (position < mAnchorPageBreakPosition) {
// If the child has a negative margin, we need to offset the row by a little bit
// extra so that it moves completely off screen.
RecyclerView.LayoutParams params = getParams(child);
int extraTranslation = 0;
if (params.topMargin < 0) {
extraTranslation -= params.topMargin;
}
if (params.bottomMargin < 0) {
extraTranslation -= params.bottomMargin;
}
int translation = (int) ((listTopInWindow + extraTranslation)
* mDanglingRowInterpolator.getInterpolation(scrollPercentage));
child.setAlpha(1f);
child.setTranslationY(-translation);
} else {
child.setAlpha(1f);
child.setTranslationY(0);
}
}
}
/**
* Update the page break positions based on the position of the views on screen. This should
* be called whenever view move or change such as during a scroll or layout.
*/
private void updatePageBreakPositions() {
if (getChildCount() == 0) {
if (DEBUG) {
Log.d(TAG, ":: updatePageBreakPosition getChildCount: 0");
}
return;
}
if (DEBUG) {
Log.v(TAG, String.format(":: #BEFORE updatePageBreakPositions " +
"mAnchorPageBreakPosition:%s, mUpperPageBreakPosition:%s, "
+ "mLowerPageBreakPosition:%s",
mAnchorPageBreakPosition, mUpperPageBreakPosition, mLowerPageBreakPosition));
}
// If the item count has changed, our page boundaries may no longer be accurate. This will
// force the page boundaries to reset around the current view that is closest to the top.
if (getItemCount() != mItemCountDuringLastPageBreakUpdate) {
if (DEBUG) {
Log.d(TAG, "Item count changed. Resetting page break positions.");
}
mAnchorPageBreakPosition = getPosition(getFirstFullyVisibleChild());
}
mItemCountDuringLastPageBreakUpdate = getItemCount();
if (mAnchorPageBreakPosition == -1) {
Log.w(TAG, "Unable to update anchor positions. There is no anchor position.");
return;
}
View anchorPageBreakView = findViewByPosition(mAnchorPageBreakPosition);
if (anchorPageBreakView == null) {
return;
}
int topMargin = getParams(anchorPageBreakView).topMargin;
int anchorTop = getDecoratedTop(anchorPageBreakView) - topMargin;
View upperPageBreakView = findViewByPosition(mUpperPageBreakPosition);
int upperPageBreakTop = upperPageBreakView == null ? Integer.MIN_VALUE :
getDecoratedTop(upperPageBreakView) - getParams(upperPageBreakView).topMargin;
if (DEBUG) {
Log.v(TAG, String.format(":: #MID updatePageBreakPositions topMargin:%s, anchorTop:%s"
+ " mAnchorPageBreakPosition:%s, mUpperPageBreakPosition:%s, "
+ "mLowerPageBreakPosition:%s", topMargin, anchorTop,
mAnchorPageBreakPosition, mUpperPageBreakPosition, mLowerPageBreakPosition));
}
if (anchorTop < getPaddingTop()) {
// The anchor has moved above the viewport. We are now on the next page. Shift the page
// break positions and calculate a new lower one.
mUpperPageBreakPosition = mAnchorPageBreakPosition;
mAnchorPageBreakPosition = mLowerPageBreakPosition;
mLowerPageBreakPosition = calculateNextPageBreakPosition(mAnchorPageBreakPosition);
} else if (mAnchorPageBreakPosition > 0 && upperPageBreakTop >= getPaddingTop()) {
// The anchor has moved below the viewport. We are now on the previous page. Shift
// the page break positions and calculate a new upper one.
mLowerPageBreakPosition = mAnchorPageBreakPosition;
mAnchorPageBreakPosition = mUpperPageBreakPosition;
mUpperPageBreakPosition = calculatePreviousPageBreakPosition(mAnchorPageBreakPosition);
} else {
mUpperPageBreakPosition = calculatePreviousPageBreakPosition(mAnchorPageBreakPosition);
mLowerPageBreakPosition = calculateNextPageBreakPosition(mAnchorPageBreakPosition);
}
if (DEBUG) {
Log.v(TAG, String.format(":: #AFTER updatePageBreakPositions " +
"mAnchorPageBreakPosition:%s, mUpperPageBreakPosition:%s, "
+ "mLowerPageBreakPosition:%s",
mAnchorPageBreakPosition, mUpperPageBreakPosition, mLowerPageBreakPosition));
}
}
/**
* @return The page break position of the page before the anchor page break position. However,
* if it reaches the end of the laid out children or position 0, it will just return
* that.
*/
private int calculatePreviousPageBreakPosition(int position) {
if (position == -1) {
return -1;
}
View referenceView = findViewByPosition(position);
int referenceViewTop = getDecoratedTop(referenceView) - getParams(referenceView).topMargin;
int previousPagePosition = position;
while (previousPagePosition > 0) {
previousPagePosition--;
View child = findViewByPosition(previousPagePosition);
if (child == null) {
// View has not been laid out yet.
return previousPagePosition + 1;
}
int childTop = getDecoratedTop(child) - getParams(child).topMargin;
if (childTop < referenceViewTop - getHeight()) {
return previousPagePosition + 1;
}
}
// Beginning of the list.
return 0;
}
/**
* @return The page break position of the next page after the anchor page break position.
* However, if it reaches the end of the laid out children or end of the list, it will
* just return that.
*/
private int calculateNextPageBreakPosition(int position) {
if (position == -1) {
return -1;
}
View referenceView = findViewByPosition(position);
if (referenceView == null) {
return position;
}
int referenceViewTop = getDecoratedTop(referenceView) - getParams(referenceView).topMargin;
int nextPagePosition = position;
// Search for the first child item after the referenceView that didn't fully fit on to the
// screen. The next page should start from the item before this child, so that users have
// a visual anchoring point of the page change.
while (position < getItemCount() - 1) {
nextPagePosition++;
View child = findViewByPosition(nextPagePosition);
if (child == null) {
// The next view has not been laid out yet.
return nextPagePosition - 1;
}
int childBottom = getDecoratedBottom(child) + getParams(child).bottomMargin;
if (childBottom - referenceViewTop > getHeight() - getPaddingTop()) {
// If choosing the previous child causes the view to snap back to the referenceView
// position, then skip that and go directly to the child. This avoids the case
// where a tall card in the layout causes the view to constantly snap back to
// the top when scrolled.
return nextPagePosition - 1 == position ? nextPagePosition : nextPagePosition - 1;
}
}
// End of the list.
return nextPagePosition;
}
/**
* In this style, the focus will scroll down to the middle of the screen and lock there
* so that moving in either direction will move the entire list by 1.
*/
private boolean onRequestChildFocusMarioStyle(RecyclerView parent, View child) {
int focusedPosition = getPosition(child);
if (focusedPosition == mLastChildPositionToRequestFocus) {
return true;
}
mLastChildPositionToRequestFocus = focusedPosition;
int availableHeight = getAvailableHeight();
int focusedChildTop = getDecoratedTop(child);
int focusedChildBottom = getDecoratedBottom(child);
int childIndex = parent.indexOfChild(child);
// Iterate through children starting at the focused child to find the child above it to
// smooth scroll to such that the focused child will be as close to the middle of the screen
// as possible.
for (int i = childIndex; i >= 0; i--) {
View childAtI = getChildAt(i);
if (childAtI == null) {
Log.e(TAG, "Child is null at index " + i);
continue;
}
// We haven't found a view that is more than half of the recycler view height above it
// but we've reached the top so we can't go any further.
if (i == 0) {
parent.smoothScrollToPosition(getPosition(childAtI));
break;
}
// Because we want to scroll to the first view that is less than half of the screen
// away from the focused view, we "look ahead" one view. When the look ahead view
// is more than availableHeight / 2 away, the current child at i is the one we want to
// scroll to. However, sometimes, that view can be null (ie, if the view is in
// transition). In that case, just skip that view.
View childBefore = getChildAt(i - 1);
if (childBefore == null) {
continue;
}
int distanceToChildBeforeFromTop = focusedChildTop - getDecoratedTop(childBefore);
int distanceToChildBeforeFromBottom = focusedChildBottom - getDecoratedTop(childBefore);
if (distanceToChildBeforeFromTop > availableHeight / 2
|| distanceToChildBeforeFromBottom > availableHeight) {
parent.smoothScrollToPosition(getPosition(childAtI));
break;
}
}
return true;
}
/**
* In this style, you can free scroll in the middle of the list but if you get to the edge,
* the list will advance to ensure that there is context ahead of the focused item.
*/
private boolean onRequestChildFocusSuperMarioStyle(RecyclerView parent,
RecyclerView.State state, View child) {
int focusedPosition = getPosition(child);
if (focusedPosition == mLastChildPositionToRequestFocus) {
return true;
}
mLastChildPositionToRequestFocus = focusedPosition;
int bottomEdgeThatMustBeOnScreen;
int focusedIndex = parent.indexOfChild(child);
// The amount of the last card at the end that must be showing to count as visible.
int peekAmount = mContext.getResources()
.getDimensionPixelSize(R.dimen.car_last_card_peek_amount);
if (focusedPosition == state.getItemCount() - 1) {
// The last item is focused.
bottomEdgeThatMustBeOnScreen = getDecoratedBottom(child);
} else if (focusedIndex == getChildCount() - 1) {
// The last laid out item is focused. Scroll enough so that the next card has at least
// the peek size visible
ViewGroup.MarginLayoutParams params =
(ViewGroup.MarginLayoutParams) child.getLayoutParams();
// We add params.topMargin as an estimate because we don't actually know the top margin
// of the next row.
bottomEdgeThatMustBeOnScreen = getDecoratedBottom(child) +
params.bottomMargin + params.topMargin + peekAmount;
} else {
View nextChild = getChildAt(focusedIndex + 1);
bottomEdgeThatMustBeOnScreen = getDecoratedTop(nextChild) + peekAmount;
}
if (bottomEdgeThatMustBeOnScreen > getHeight()) {
// We're going to have to scroll because the bottom edge that must be on screen is past
// the bottom.
int topEdgeToFindViewUnder = getPaddingTop() +
bottomEdgeThatMustBeOnScreen - getHeight();
View nextChild = null;
for (int i = 0; i < getChildCount(); i++) {
View potentialNextChild = getChildAt(i);
RecyclerView.LayoutParams params = getParams(potentialNextChild);
float top = getDecoratedTop(potentialNextChild) - params.topMargin;
if (top >= topEdgeToFindViewUnder) {
nextChild = potentialNextChild;
break;
}
}
if (nextChild == null) {
Log.e(TAG, "There is no view under " + topEdgeToFindViewUnder);
return true;
}
int nextChildPosition = getPosition(nextChild);
parent.smoothScrollToPosition(nextChildPosition);
} else {
int firstFullyVisibleIndex = getFirstFullyVisibleChildIndex();
if (focusedIndex <= firstFullyVisibleIndex) {
parent.smoothScrollToPosition(Math.max(focusedPosition - 1, 0));
}
}
return true;
}
/**
* We don't actually know the size of every single view, only what is currently laid out.
* This makes it difficult to do accurate scrollbar calculations. However, lists in the car
* often consist of views with identical heights. Because of that, we can use
* a single sample view to do our calculations for. The main exceptions are in the first items
* of a list (hero card, last call card, etc) so if the first view is at position 0, we pick
* the next one.
*
* @return The decorated measured height of the sample view plus its margins.
*/
private int getSampleViewHeight() {
if (mSampleViewHeight != -1) {
return mSampleViewHeight;
}
int sampleViewIndex = getFirstFullyVisibleChildIndex();
View sampleView = getChildAt(sampleViewIndex);
if (getPosition(sampleView) == 0 && sampleViewIndex < getChildCount() - 1) {
sampleView = getChildAt(++sampleViewIndex);
}
RecyclerView.LayoutParams params = getParams(sampleView);
int height =
getDecoratedMeasuredHeight(sampleView) + params.topMargin + params.bottomMargin;
if (height == 0) {
// This can happen if the view isn't measured yet.
Log.w(TAG, "The sample view has a height of 0. Returning a dummy value for now " +
"that won't be cached.");
height = mContext.getResources().getDimensionPixelSize(R.dimen.car_sample_row_height);
} else {
mSampleViewHeight = height;
}
return height;
}
/**
* @return The height of the RecyclerView excluding padding.
*/
private int getAvailableHeight() {
return getHeight() - getPaddingTop() - getPaddingBottom();
}
/**
* @return {@link RecyclerView.LayoutParams} for the given view or null if it isn't a child
* of {@link RecyclerView}.
*/
private static RecyclerView.LayoutParams getParams(View view) {
return (RecyclerView.LayoutParams) view.getLayoutParams();
}
/**
* Custom {@link LinearSmoothScroller} that has:
* a) Custom control over the speed of scrolls.
* b) Scrolling snaps to start. All of our scrolling logic depends on that.
* c) Keeps track of some state of the current scroll so that can aid in things like
* the scrollbar calculations.
*/
private final class CarSmoothScroller extends LinearSmoothScroller {
/** This value (150) was hand tuned by UX for what felt right. **/
private static final float MILLISECONDS_PER_INCH = 150f;
/** This value (0.45) was hand tuned by UX for what felt right. **/
private static final float DECELERATION_TIME_DIVISOR = 0.45f;
private static final int NON_TOUCH_MAX_DECELERATION_MS = 1000;
/** This value (1.8) was hand tuned by UX for what felt right. **/
private final Interpolator mInterpolator = new DecelerateInterpolator(1.8f);
private final boolean mHasTouch;
private final int mTargetPosition;
public CarSmoothScroller(Context context, int targetPosition) {
super(context);
mTargetPosition = targetPosition;
mHasTouch = mContext.getResources().getBoolean(R.bool.car_true_for_touch);
}
@Override
public PointF computeScrollVectorForPosition(int i) {
if (getChildCount() == 0) {
return null;
}
final int firstChildPos = getPosition(getChildAt(getFirstFullyVisibleChildIndex()));
final int direction = (mTargetPosition < firstChildPos) ? -1 : 1;
return new PointF(0, direction);
}
@Override
protected int getVerticalSnapPreference() {
// This is key for most of the scrolling logic that guarantees that scrolling
// will settle with a view aligned to the top.
return LinearSmoothScroller.SNAP_TO_START;
}
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int dy = calculateDyToMakeVisible(targetView, SNAP_TO_START);
if (dy == 0) {
if (DEBUG) {
Log.d(TAG, "Scroll distance is 0");
}
return;
}
final int time = calculateTimeForDeceleration(dy);
if (time > 0) {
action.update(0, -dy, time, mInterpolator);
}
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}
@Override
protected int calculateTimeForDeceleration(int dx) {
int time = (int) Math.ceil(calculateTimeForScrolling(dx) / DECELERATION_TIME_DIVISOR);
return mHasTouch ? time : Math.min(time, NON_TOUCH_MAX_DECELERATION_MS);
}
public int getTargetPosition() {
return mTargetPosition;
}
}
}