| /* |
| * 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.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; |
| |
| /** |
| * 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. |
| * |
| * <p>Snapping may be disabled for views whose height is greater than that of the |
| * {@code RecyclerView} that contains them. In this case, the view will only be snapped to when it |
| * is first encountered. Otherwise, the user will be allowed to scroll freely through that view |
| * when it appears in the list. The snapping behavior will resume when the large view is scrolled |
| * off-screen. |
| */ |
| 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; |
| |
| /** |
| * When a View is longer than containing RecyclerView, the percentage of the end of this View |
| * that needs to be completely visible to prevent the rest of views to be a viable snap target. |
| * |
| * <p>In other words, if a longer-than-screen View takes more than threshold screen space on its |
| * end, do not snap to any View. |
| */ |
| private static final float LONG_ITEM_END_VISIBLE_THRESHOLD = 0.3f; |
| |
| private final Context mContext; |
| private RecyclerView mRecyclerView; |
| |
| public PagedSnapHelper(Context context) { |
| mContext = context; |
| } |
| |
| // Orientation helpers are lazily created per LayoutManager. |
| @Nullable private OrientationHelper mVerticalHelper; |
| @Nullable private OrientationHelper mHorizontalHelper; |
| |
| @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. Returns {@code null}when: |
| * <ul> |
| * <li>there is no item; or |
| * <li>no visible item can fully fit in the containing RecyclerView; or |
| * <li>an item longer than containing RecyclerView is about to scroll out. |
| * </ul> |
| */ |
| @Override |
| @Nullable |
| public View findSnapView(RecyclerView.LayoutManager layoutManager) { |
| int childCount = layoutManager.getChildCount(); |
| if (childCount == 0) { |
| return null; |
| } |
| |
| OrientationHelper orientationHelper = getOrientationHelper(layoutManager); |
| |
| // If there's only one child, then that will be the snap target. |
| if (childCount == 1) { |
| View firstChild = layoutManager.getChildAt(0); |
| return isValidSnapView(firstChild, orientationHelper) ? firstChild : null; |
| } |
| |
| // If the top child view is longer than the RecyclerView (long item), and it's not yet |
| // scrolled out - meaning the screen it takes up is more than threshold, |
| // do not snap to any view. |
| // This way avoids next View snapping to top "pushes" out the end of a long item. |
| View firstChild = mRecyclerView.getChildAt(0); |
| if (firstChild.getHeight() > mRecyclerView.getHeight() |
| // Long item start is scrolled past screen; |
| && orientationHelper.getDecoratedStart(firstChild) < 0 |
| // and it takes up more than threshold screen size. |
| && orientationHelper.getDecoratedEnd(firstChild) > ( |
| mRecyclerView.getHeight() * LONG_ITEM_END_VISIBLE_THRESHOLD)) { |
| return null; |
| } |
| |
| 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; |
| |
| // Iterate to find the child closest to the top and more than half way visible. |
| 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); |
| |
| if (percentageVisible > VIEW_VISIBLE_THRESHOLD |
| && percentageVisible > closestPercentageVisible) { |
| closestDistanceToStart = startOffset; |
| closestChild = child; |
| closestPercentageVisible = percentageVisible; |
| } |
| } |
| } |
| |
| View childToReturn = closestChild; |
| |
| // If closestChild is null, then that means we were unable to find a closest child that |
| // is over the VIEW_VISIBLE_THRESHOLD. This could happen if the views are larger than |
| // the given area. In this case, consider returning the lastVisibleChild so that the screen |
| // scrolls. Also, check if the last item should be displayed anyway if it is mostly visible. |
| if ((childToReturn == null |
| || (lastItemVisible && lastItemPercentageVisible > closestPercentageVisible))) { |
| childToReturn = lastVisibleChild; |
| } |
| |
| // Return null if the childToReturn is not valid. This allows the user to scroll freely |
| // with no snapping. This can allow them to see the entire view. |
| return isValidSnapView(childToReturn, orientationHelper) ? childToReturn : null; |
| } |
| |
| /** |
| * Returns whether or not the given View is a valid snapping view. A view is considered valid |
| * for snapping if it can fit entirely within the height of the RecyclerView it is contained |
| * within. |
| * |
| * <p>If the view is larger than the RecyclerView, then it might not want to be snapped to |
| * to allow the user to scroll and see the rest of the View. |
| * |
| * @param view The view to determine the snapping potential. |
| * @param helper The {@link OrientationHelper} associated with the current RecyclerView. |
| * @return {@code true} if the given view is a valid snapping view; {@code false} otherwise. |
| */ |
| private boolean isValidSnapView(View view, OrientationHelper helper) { |
| return helper.getDecoratedMeasurement(view) <= helper.getLayoutManager().getHeight(); |
| } |
| |
| /** |
| * 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 new PagedSmoothScroller(mContext); |
| } |
| |
| /** |
| * 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. */ |
| 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(); |
| } |
| |
| /** |
| * 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)); |
| } |
| } |