| /* |
| * 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.recyclerview; |
| |
| 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; |
| import androidx.recyclerview.widget.RecyclerView.LayoutManager; |
| |
| import java.util.Objects; |
| |
| /** |
| * 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 CarUiSnapHelper 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 CarUiSnapHelper(Context context) { |
| mContext = context; |
| } |
| |
| // 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]; |
| |
| // Don't snap when not in touch mode, i.e. when using rotary. |
| if (!mRecyclerView.isInTouchMode()) { |
| return out; |
| } |
| |
| if (layoutManager.canScrollHorizontally()) { |
| out[0] = distanceToTopMargin(targetView, getHorizontalHelper(layoutManager)); |
| } |
| |
| if (layoutManager.canScrollVertically()) { |
| out[1] = distanceToTopMargin(targetView, getVerticalHelper(layoutManager)); |
| } |
| |
| 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 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(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 (mRecyclerView == null) { |
| return 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; |
| } |
| |
| @NonNull View lastVisibleChild = Objects.requireNonNull( |
| 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; |
| } |
| |
| 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 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 static boolean isValidSnapView(View view, OrientationHelper helper) { |
| return helper.getDecoratedMeasurement(view) <= helper.getTotalSpace(); |
| } |
| |
| /** |
| * 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. |
| */ |
| static float getPercentageVisible(View view, OrientationHelper helper) { |
| int start = helper.getStartAfterPadding(); |
| int end = helper.getEndAfterPadding(); |
| |
| 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 (viewEnd <= start) { |
| // The view is above the visible area of the RecyclerView. |
| return 0; |
| } else if (viewStart >= end) { |
| // The view is below the visible area of the RecyclerView. |
| return 0; |
| } else if (viewStart <= start && viewEnd >= end) { |
| // The view is larger than the height of the RecyclerView. |
| return ((float) end - start) / helper.getDecoratedMeasurement(view); |
| } else if (viewStart < start) { |
| // The view is above the start of the RecyclerView. |
| return ((float) viewEnd - start) / helper.getDecoratedMeasurement(view); |
| } else { |
| // The view is below the end of the RecyclerView. |
| return ((float) end - viewStart) / 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 LayoutManager} associated with the attached |
| * {@link RecyclerView}. |
| * @return a {@link RecyclerView.SmoothScroller} which will handle the scrolling. |
| */ |
| @Override |
| protected RecyclerView.SmoothScroller createScroller(@NonNull LayoutManager layoutManager) { |
| return new CarUiSmoothScroller(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; |
| } |
| |
| LayoutManager layoutManager = mRecyclerView.getLayoutManager(); |
| if (layoutManager == null || layoutManager.getChildCount() == 0) { |
| return outDist; |
| } |
| |
| int lastChildPosition = isAtEnd(layoutManager) ? 0 : layoutManager.getChildCount() - 1; |
| |
| OrientationHelper orientationHelper = getOrientationHelper(layoutManager); |
| @NonNull View lastChild = Objects.requireNonNull( |
| 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; |
| } |
| |
| /** |
| * Estimates a position to which CarUiSnapHelper will try to snap to for a requested scroll |
| * distance. |
| * |
| * @param helper The {@link OrientationHelper} that is created from the LayoutManager. |
| * @param scrollDistance The intended scroll distance. |
| * |
| * @return The diff between the target snap position and the current position. |
| */ |
| public int estimateNextPositionDiffForScrollDistance(OrientationHelper helper, |
| int scrollDistance) { |
| float distancePerChild = computeDistancePerChild(helper.getLayoutManager(), helper); |
| if (distancePerChild <= 0) { |
| return 0; |
| } |
| return (int) Math.round(scrollDistance / distancePerChild); |
| } |
| |
| /** |
| * This method is taken verbatim from the [androidx] {@link LinearSnapHelper} private method |
| * implementation. |
| * |
| * Computes an average pixel value to pass a single child. |
| * <p> |
| * Returns a negative value if it cannot be calculated. |
| * |
| * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached |
| * {@link RecyclerView}. |
| * @param helper The relevant {@link OrientationHelper} for the attached |
| * {@link RecyclerView.LayoutManager}. |
| * |
| * @return A float value that is the average number of pixels needed to scroll by one view in |
| * the relevant direction. |
| */ |
| 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); |
| final 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 0; |
| } |
| return 1f * distance / ((maxPos - minPos) + 1); |
| } |
| |
| /** |
| * Returns {@code true} if the RecyclerView is completely displaying the first item. |
| */ |
| public boolean isAtStart(@Nullable LayoutManager layoutManager) { |
| if (layoutManager == null || layoutManager.getChildCount() == 0) { |
| return true; |
| } |
| |
| @NonNull View firstChild = Objects.requireNonNull(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(@Nullable LayoutManager layoutManager) { |
| if (layoutManager == null || layoutManager.getChildCount() == 0) { |
| return true; |
| } |
| |
| int childCount = layoutManager.getChildCount(); |
| OrientationHelper orientationHelper = |
| layoutManager.canScrollVertically() ? getVerticalHelper(layoutManager) |
| : getHorizontalHelper(layoutManager); |
| |
| @NonNull View lastVisibleChild = Objects.requireNonNull( |
| 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 LayoutManager}. |
| */ |
| @NonNull |
| private OrientationHelper getOrientationHelper(@NonNull LayoutManager layoutManager) { |
| return layoutManager.canScrollVertically() |
| ? getVerticalHelper(layoutManager) |
| : getHorizontalHelper(layoutManager); |
| } |
| |
| @NonNull |
| private OrientationHelper getVerticalHelper(@NonNull LayoutManager layoutManager) { |
| if (mVerticalHelper == null || mVerticalHelper.getLayoutManager() != layoutManager) { |
| mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager); |
| } |
| return mVerticalHelper; |
| } |
| |
| @NonNull |
| private OrientationHelper getHorizontalHelper(@NonNull 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)); |
| } |
| } |