blob: 9bf1fb6d375f7db2d12f26d2af00d3be51741760 [file] [log] [blame]
/*
* Copyright (C) 2017 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 androidx.car.widget;
import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.widget.LinearSnapHelper;
import android.support.v7.widget.OrientationHelper;
import android.support.v7.widget.RecyclerView;
import android.view.View;
/**
* 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 {
/**
* The percentage of a View that needs to be completely visible for it to be a viable snap
* target.
*/
private static final float VIEW_VISIBLE_THRESHOLD = 0.5f;
private final PagedSmoothScroller mSmoothScroller;
private RecyclerView mRecyclerView;
// Orientation helpers are lazily created per LayoutManager.
@Nullable
private OrientationHelper mVerticalHelper;
@Nullable
private OrientationHelper mHorizontalHelper;
public PagedSnapHelper(Context context) {
mSmoothScroller = new PagedSmoothScroller(context);
}
@Override
public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager,
@NonNull View targetView) {
int[] out = new int[2];
out[0] = layoutManager.canScrollHorizontally()
? getHorizontalHelper(layoutManager).getDecoratedStart(targetView)
: 0;
out[1] = layoutManager.canScrollVertically()
? getVerticalHelper(layoutManager).getDecoratedStart(targetView)
: 0;
return out;
}
/**
* 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.
*/
@Override
@Nullable
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}
// If there's only one child, then that will be the snap target.
if (childCount == 1) {
return layoutManager.getChildAt(0);
}
OrientationHelper orientationHelper = layoutManager.canScrollVertically()
? getVerticalHelper(layoutManager)
: getHorizontalHelper(layoutManager);
View lastVisibleChild = layoutManager.getChildAt(childCount - 1);
// Check if the last child visible is the last item in the list.
boolean lastItemVisible =
layoutManager.getPosition(lastVisibleChild) == layoutManager.getItemCount() - 1;
// If it is, then check how much of that view is visible.
float lastItemPercentageVisible = lastItemVisible
? getPercentageVisible(lastVisibleChild, orientationHelper) : 0;
View closestChild = null;
int closestDistanceToStart = Integer.MAX_VALUE;
float closestPercentageVisible = 0.f;
for (int i = 0; i < childCount; i++) {
View child = layoutManager.getChildAt(i);
int startOffset = orientationHelper.getDecoratedStart(child);
if (Math.abs(startOffset) < closestDistanceToStart) {
float percentageVisible = getPercentageVisible(child, orientationHelper);
// Only snap to the child that is closest to the top and is more than
// half-way visible.
if (percentageVisible > VIEW_VISIBLE_THRESHOLD
&& percentageVisible > closestPercentageVisible) {
closestDistanceToStart = startOffset;
closestChild = child;
closestPercentageVisible = percentageVisible;
}
}
}
// Snap to the last child in the list if it's the last item in the list, and it's more
// visible than the closest item to the top of the list.
return (lastItemVisible && lastItemPercentageVisible > closestPercentageVisible)
? lastVisibleChild
: 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 float getPercentageVisible(View view, OrientationHelper helper) {
int start = 0;
int end = helper.getEnd();
int viewStart = helper.getDecoratedStart(view);
int viewEnd = helper.getDecoratedEnd(view);
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.
int viewHeight = helper.getDecoratedMeasurement(view);
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) {
super.attachToRecyclerView(recyclerView);
mRecyclerView = 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;
// 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.
int maxDistance = layoutManager.getHeight() - layoutManager.getDecoratedMeasuredHeight(
layoutManager.getChildAt(lastChildPosition));
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. */
public 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) >= 0
&& 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();
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) <= layoutManager.getHeight();
}
@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));
}
}