blob: e84ee75625db46a034bc5cb0b174850708a45432 [file] [log] [blame]
/*
* Copyright (C) 2019 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.ui.pagedrecyclerview;
import android.content.Context;
import android.graphics.PointF;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearSnapHelper;
import androidx.recyclerview.widget.OrientationHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.LayoutManager;
/**
* Inspired by {@link androidx.car.widget.PagedSnapHelper}
*
* <p>Extension of a {@link LinearSnapHelper} that will snap to the start of the target child view
* to the start of the attached {@link RecyclerView}. The start of the view is defined as the top if
* the RecyclerView is scrolling vertically; it is defined as the left (or right if RTL) if the
* RecyclerView is scrolling horizontally.
*/
public class PagedSnapHelper extends LinearSnapHelper {
private final Context mContext;
private RecyclerView mRecyclerView;
private RecyclerView.SmoothScroller mSmoothScroller;
public PagedSnapHelper(Context context) {
this.mContext = context;
mSmoothScroller = new PagedSmoothScroller(mContext);
}
// Orientation helpers are lazily created per LayoutManager.
@Nullable
private OrientationHelper mVerticalHelper;
@Nullable
private OrientationHelper mHorizontalHelper;
@Override
public int[] calculateDistanceToFinalSnap(
@NonNull LayoutManager layoutManager, @NonNull View targetView) {
int[] out = new int[2];
if (layoutManager.canScrollHorizontally()) {
out[0] = distanceToTopMargin(targetView, getHorizontalHelper(layoutManager));
} else {
out[0] = 0;
}
if (layoutManager.canScrollVertically()) {
out[1] = distanceToTopMargin(targetView, getVerticalHelper(layoutManager));
} else {
out[1] = 0;
}
return out;
}
/**
* Smooth scrolls the RecyclerView by a given distance.
*/
public void smoothScrollBy(int scrollDistance) {
int position = findTargetSnapPosition(mRecyclerView.getLayoutManager(), scrollDistance);
if (position == RecyclerView.NO_POSITION) {
mRecyclerView.smoothScrollBy(0, scrollDistance);
return;
}
mSmoothScroller.setTargetPosition(position);
mRecyclerView.getLayoutManager().startSmoothScroll(mSmoothScroller);
}
/**
* Finds the target position for snapping.
*
* @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
* {@link RecyclerView}
*/
private int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager,
int scrollDistance) {
if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
return RecyclerView.NO_POSITION;
}
final int itemCount = layoutManager.getItemCount();
if (itemCount == 0) {
return RecyclerView.NO_POSITION;
}
final View currentView = findViewIfScrollable(layoutManager);
if (currentView == null) {
return RecyclerView.NO_POSITION;
}
final int currentPosition = layoutManager.getPosition(currentView);
if (currentPosition == RecyclerView.NO_POSITION) {
return RecyclerView.NO_POSITION;
}
RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider =
(RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager;
// deltaJumps sign comes from the velocity which may not match the order of children in
// the LayoutManager. To overcome this, we ask for a vector from the LayoutManager to
// get the direction.
PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
if (vectorForEnd == null) {
// cannot get a vector for the given position.
return RecyclerView.NO_POSITION;
}
int vDeltaJump;
int hDeltaJump;
if (layoutManager.canScrollHorizontally()) {
hDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getHorizontalHelper(layoutManager), scrollDistance);
if (vectorForEnd.x < 0) {
hDeltaJump = -hDeltaJump;
}
} else {
hDeltaJump = 0;
}
if (layoutManager.canScrollVertically()) {
vDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getVerticalHelper(layoutManager), scrollDistance);
if (vectorForEnd.y < 0) {
vDeltaJump = -vDeltaJump;
}
} else {
vDeltaJump = 0;
}
int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump;
if (deltaJump == 0) {
return RecyclerView.NO_POSITION;
}
int targetPos = currentPosition + deltaJump;
if (targetPos < 0) {
targetPos = 0;
}
if (targetPos >= itemCount) {
targetPos = itemCount - 1;
}
return targetPos;
}
@Override
public View findSnapView(LayoutManager layoutManager) {
OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
if (mRecyclerView.computeVerticalScrollRange() - mRecyclerView.computeVerticalScrollOffset()
<= orientationHelper.getTotalSpace()
+ mRecyclerView.getPaddingTop()
+ mRecyclerView.getPaddingBottom()) {
return null;
}
return findViewIfScrollable(layoutManager);
}
private View findViewIfScrollable(LayoutManager layoutManager) {
if (layoutManager.canScrollVertically()) {
return findTopView(layoutManager, getVerticalHelper(layoutManager));
} else if (layoutManager.canScrollHorizontally()) {
return findTopView(layoutManager, getHorizontalHelper(layoutManager));
}
return null;
}
private static int distanceToTopMargin(@NonNull View targetView, OrientationHelper helper) {
final int childTop = helper.getDecoratedStart(targetView);
final int containerTop = helper.getStartAfterPadding();
return childTop - containerTop;
}
/**
* Finds the view to snap to. The view to snap to is the child of the LayoutManager that is
* closest to the start of the RecyclerView. The "start" depends on if the LayoutManager is
* scrolling horizontally or vertically. If it is horizontally scrolling, then the start is the
* view on the left (right if RTL). Otherwise, it is the top-most view.
*
* @param layoutManager The current {@link RecyclerView.LayoutManager} for the attached
* RecyclerView.
* @return The View closest to the start of the RecyclerView.
*/
private static View findTopView(LayoutManager layoutManager, OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}
View closestChild = null;
int absClosest = Integer.MAX_VALUE;
for (int i = 0; i < childCount; i++) {
View child = layoutManager.getChildAt(i);
if (child == null) {
continue;
}
int absDistance = Math.abs(distanceToTopMargin(child, helper));
/** if child top is closer than previous closest, set it as closest */
if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}
/**
* Returns the percentage of the given view that is visible, relative to its containing
* RecyclerView.
*
* @param view The View to get the percentage visible of.
* @param helper An {@link OrientationHelper} to aid with calculation.
* @return A float indicating the percentage of the given view that is visible.
*/
private static float getPercentageVisible(View view, OrientationHelper helper) {
int start = helper.getStartAfterPadding();
int end = helper.getEndAfterPadding();
int viewHeight = helper.getDecoratedMeasurement(view);
int viewStart = helper.getDecoratedStart(view);
int viewEnd = helper.getDecoratedEnd(view);
if (viewEnd < start) {
// The is outside of the bounds of the recyclerView.
return 0f;
} else if (viewStart >= start && viewEnd <= end) {
// The view is within the bounds of the RecyclerView, so it's fully visible.
return 1.f;
} else if (viewStart <= start && viewEnd >= end) {
// The view is larger than the height of the RecyclerView.
return 1.f - ((float) (Math.abs(viewStart) + Math.abs(viewEnd)) / viewHeight);
} else if (viewStart < start) {
// The view is above the start of the RecyclerView, so subtract the start offset
// from the total height.
return 1.f - ((float) Math.abs(viewStart) / helper.getDecoratedMeasurement(view));
} else {
// The view is below the end of the RecyclerView, so subtract the end offset from the
// total height.
return 1.f - ((float) Math.abs(viewEnd) / helper.getDecoratedMeasurement(view));
}
}
@Override
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
this.mRecyclerView = recyclerView;
super.attachToRecyclerView(recyclerView);
}
/**
* Returns a scroller specific to this {@code PagedSnapHelper}. This scroller is used for all
* smooth scrolling operations, including flings.
*
* @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
* {@link
* RecyclerView}.
* @return a {@link RecyclerView.SmoothScroller} which will handle the scrolling.
*/
@Override
protected RecyclerView.SmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
return mSmoothScroller;
}
/**
* Calculate the estimated scroll distance in each direction given velocities on both axes. This
* method will clamp the maximum scroll distance so that a single fling will never scroll more
* than one page.
*
* @param velocityX Fling velocity on the horizontal axis.
* @param velocityY Fling velocity on the vertical axis.
* @return An array holding the calculated distances in x and y directions respectively.
*/
@Override
public int[] calculateScrollDistance(int velocityX, int velocityY) {
int[] outDist = super.calculateScrollDistance(velocityX, velocityY);
if (mRecyclerView == null) {
return outDist;
}
RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager == null || layoutManager.getChildCount() == 0) {
return outDist;
}
int lastChildPosition = isAtEnd(layoutManager) ? 0 : layoutManager.getChildCount() - 1;
OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
View lastChild = layoutManager.getChildAt(lastChildPosition);
float percentageVisible = getPercentageVisible(lastChild, orientationHelper);
int maxDistance = layoutManager.getHeight();
if (percentageVisible > 0.f) {
// The max and min distance is the total height of the RecyclerView minus the height of
// the last child. This ensures that each scroll will never scroll more than a single
// page on the RecyclerView. That is, the max scroll will make the last child the
// first child and vice versa when scrolling the opposite way.
maxDistance -= layoutManager.getDecoratedMeasuredHeight(lastChild);
}
int minDistance = -maxDistance;
outDist[0] = clamp(outDist[0], minDistance, maxDistance);
outDist[1] = clamp(outDist[1], minDistance, maxDistance);
return outDist;
}
/** Returns {@code true} if the RecyclerView is completely displaying the first item. */
boolean isAtStart(RecyclerView.LayoutManager layoutManager) {
if (layoutManager == null || layoutManager.getChildCount() == 0) {
return true;
}
View firstChild = layoutManager.getChildAt(0);
OrientationHelper orientationHelper =
layoutManager.canScrollVertically()
? getVerticalHelper(layoutManager)
: getHorizontalHelper(layoutManager);
// Check that the first child is completely visible and is the first item in the list.
return orientationHelper.getDecoratedStart(firstChild)
>= orientationHelper.getStartAfterPadding()
&& layoutManager.getPosition(firstChild) == 0;
}
/** Returns {@code true} if the RecyclerView is completely displaying the last item. */
public boolean isAtEnd(RecyclerView.LayoutManager layoutManager) {
if (layoutManager == null || layoutManager.getChildCount() == 0) {
return true;
}
int childCount = layoutManager.getChildCount();
OrientationHelper orientationHelper =
layoutManager.canScrollVertically()
? getVerticalHelper(layoutManager)
: getHorizontalHelper(layoutManager);
View lastVisibleChild = layoutManager.getChildAt(childCount - 1);
// The list has reached the bottom if the last child that is visible is the last item
// in the list and it's fully shown.
return layoutManager.getPosition(lastVisibleChild) == (layoutManager.getItemCount() - 1)
&& layoutManager.getDecoratedBottom(lastVisibleChild)
<= orientationHelper.getEndAfterPadding();
}
/**
* Returns an {@link OrientationHelper} that corresponds to the current scroll direction of the
* given {@link RecyclerView.LayoutManager}.
*/
@NonNull
private OrientationHelper getOrientationHelper(
@NonNull RecyclerView.LayoutManager layoutManager) {
return layoutManager.canScrollVertically()
? getVerticalHelper(layoutManager)
: getHorizontalHelper(layoutManager);
}
@NonNull
private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
if (mVerticalHelper == null || mVerticalHelper.getLayoutManager() != layoutManager) {
mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
}
return mVerticalHelper;
}
@NonNull
private OrientationHelper getHorizontalHelper(
@NonNull RecyclerView.LayoutManager layoutManager) {
if (mHorizontalHelper == null || mHorizontalHelper.getLayoutManager() != layoutManager) {
mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
}
return mHorizontalHelper;
}
/**
* Ensures that the given value falls between the range given by the min and max values. This
* method does not check that the min value is greater than or equal to the max value. If the
* parameters are not well-formed, this method's behavior is undefined.
*
* @param value The value to clamp.
* @param min The minimum value the given value can be.
* @param max The maximum value the given value can be.
* @return A number that falls between {@code min} or {@code max} or one of those values if the
* given value is less than or greater than {@code min} and {@code max} respectively.
*/
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private static int estimateNextPositionDiffForFling(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper, int scrollDistance) {
int[] distances = new int[]{scrollDistance, scrollDistance};
float distancePerChild = computeDistancePerChild(layoutManager, helper);
if (distancePerChild <= 0) {
return 0;
}
int distance =
Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];
return (int) Math.round(distance / distancePerChild);
}
private static float computeDistancePerChild(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
View minPosView = null;
View maxPosView = null;
int minPos = Integer.MAX_VALUE;
int maxPos = Integer.MIN_VALUE;
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return -1;
}
for (int i = 0; i < childCount; i++) {
View child = layoutManager.getChildAt(i);
int pos = layoutManager.getPosition(child);
if (pos == RecyclerView.NO_POSITION) {
continue;
}
if (pos < minPos) {
minPos = pos;
minPosView = child;
}
if (pos > maxPos) {
maxPos = pos;
maxPosView = child;
}
}
if (minPosView == null || maxPosView == null) {
return -1;
}
int start = Math.min(helper.getDecoratedStart(minPosView),
helper.getDecoratedStart(maxPosView));
int end = Math.max(helper.getDecoratedEnd(minPosView),
helper.getDecoratedEnd(maxPosView));
int distance = end - start;
if (distance == 0) {
return -1;
}
return 1f * distance / ((maxPos - minPos) + 1);
}
}