| /* |
| * 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 static com.android.car.ui.utils.CarUiUtils.requireViewByRefId; |
| |
| import android.content.res.Resources; |
| import android.os.Handler; |
| import android.util.SparseArray; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.animation.AccelerateDecelerateInterpolator; |
| import android.view.animation.Interpolator; |
| |
| import androidx.annotation.IntRange; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.recyclerview.widget.OrientationHelper; |
| import androidx.recyclerview.widget.RecyclerView; |
| |
| import com.android.car.ui.R; |
| import com.android.car.ui.utils.CarUiUtils; |
| |
| /** |
| * The default scroll bar widget for the {@link CarUiRecyclerView}. |
| * |
| * <p>Inspired by {@link androidx.car.widget.PagedListView}. Most pagination and scrolling logic |
| * has been ported from the PLV with minor updates. |
| */ |
| class DefaultScrollBar implements ScrollBar { |
| |
| |
| private float mButtonDisabledAlpha; |
| private CarUiSnapHelper mSnapHelper; |
| |
| private View mScrollView; |
| private View mScrollTrack; |
| private View mScrollThumb; |
| private View mUpButton; |
| private View mDownButton; |
| private int mScrollbarThumbMinHeight; |
| |
| private RecyclerView mRecyclerView; |
| |
| private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator(); |
| |
| private final Handler mHandler = new Handler(); |
| |
| private OrientationHelper mOrientationHelper; |
| |
| private OnContinuousScrollListener mPageUpOnContinuousScrollListener; |
| private OnContinuousScrollListener mPageDownOnContinuousScrollListener; |
| |
| @Override |
| public void initialize(RecyclerView rv, View scrollView) { |
| mRecyclerView = rv; |
| |
| mScrollView = scrollView; |
| |
| Resources res = rv.getContext().getResources(); |
| |
| mButtonDisabledAlpha = CarUiUtils.getFloat(res, R.dimen.car_ui_button_disabled_alpha); |
| mScrollbarThumbMinHeight = rv.getContext().getResources() |
| .getDimensionPixelSize(R.dimen.car_ui_scrollbar_min_thumb_height); |
| |
| getRecyclerView().addOnScrollListener(mRecyclerViewOnScrollListener); |
| getRecyclerView().getRecycledViewPool().setMaxRecycledViews(0, 12); |
| |
| mUpButton = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_page_up); |
| View.OnClickListener paginateUpButtonOnClickListener = v -> pageUp(); |
| mUpButton.setOnClickListener(paginateUpButtonOnClickListener); |
| mPageUpOnContinuousScrollListener = new OnContinuousScrollListener(rv.getContext(), |
| paginateUpButtonOnClickListener); |
| mUpButton.setOnTouchListener(mPageUpOnContinuousScrollListener); |
| |
| |
| mDownButton = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_page_down); |
| View.OnClickListener paginateDownButtonOnClickListener = v -> pageDown(); |
| mDownButton.setOnClickListener(paginateDownButtonOnClickListener); |
| mPageDownOnContinuousScrollListener = new OnContinuousScrollListener(rv.getContext(), |
| paginateDownButtonOnClickListener); |
| mDownButton.setOnTouchListener(mPageDownOnContinuousScrollListener); |
| |
| mScrollTrack = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_track); |
| mScrollThumb = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_thumb); |
| |
| mSnapHelper = new CarUiSnapHelper(rv.getContext()); |
| getRecyclerView().setOnFlingListener(null); |
| mSnapHelper.attachToRecyclerView(getRecyclerView()); |
| |
| // enables fast scrolling. |
| FastScroller fastScroller = new FastScroller(mRecyclerView, mScrollTrack, mScrollView); |
| fastScroller.enable(); |
| |
| mScrollView.setVisibility(View.INVISIBLE); |
| mScrollView.addOnLayoutChangeListener( |
| (View v, |
| int left, |
| int top, |
| int right, |
| int bottom, |
| int oldLeft, |
| int oldTop, |
| int oldRight, |
| int oldBottom) -> mHandler.post(this::updatePaginationButtons)); |
| } |
| |
| public RecyclerView getRecyclerView() { |
| return mRecyclerView; |
| } |
| |
| @Override |
| public void requestLayout() { |
| mScrollView.requestLayout(); |
| } |
| |
| @Override |
| public void setPadding(int paddingStart, int paddingEnd) { |
| mScrollView.setPadding(mScrollView.getPaddingLeft(), paddingStart, |
| mScrollView.getPaddingRight(), paddingEnd); |
| } |
| |
| @Override |
| public void adapterChanged(@Nullable RecyclerView.Adapter adapter) { |
| try { |
| if (mRecyclerView.getAdapter() != null) { |
| mRecyclerView.getAdapter().unregisterAdapterDataObserver(mAdapterChangeObserver); |
| } |
| if (adapter != null) { |
| adapter.registerAdapterDataObserver(mAdapterChangeObserver); |
| } |
| } catch (IllegalStateException e) { |
| // adapter is already registered. and we're trying to register again. |
| // or adapter was not registered and we're trying to unregister again. |
| // ignore. |
| } |
| } |
| |
| /** |
| * Sets whether or not the up button on the scroll bar is clickable. |
| * |
| * @param enabled {@code true} if the up button is enabled. |
| */ |
| private void setUpEnabled(boolean enabled) { |
| // If the button is held down the button is disabled, the MotionEvent.ACTION_UP event on |
| // button release will not be sent to cancel pending scrolls. Manually cancel any pending |
| // scroll. |
| if (!enabled) { |
| mPageUpOnContinuousScrollListener.cancelPendingScroll(); |
| } |
| |
| mUpButton.setEnabled(enabled); |
| mUpButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); |
| } |
| |
| /** |
| * Sets whether or not the down button on the scroll bar is clickable. |
| * |
| * @param enabled {@code true} if the down button is enabled. |
| */ |
| private void setDownEnabled(boolean enabled) { |
| // If the button is held down the button is disabled, the MotionEvent.ACTION_UP event on |
| // button release will not be sent to cancel pending scrolls. Manually cancel any pending |
| // scroll. |
| if (!enabled) { |
| mPageDownOnContinuousScrollListener.cancelPendingScroll(); |
| } |
| |
| mDownButton.setEnabled(enabled); |
| mDownButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); |
| } |
| |
| /** |
| * Returns whether or not the down button on the scroll bar is clickable. |
| * |
| * @return {@code true} if the down button is enabled. {@code false} otherwise. |
| */ |
| private boolean isDownEnabled() { |
| return mDownButton.isEnabled(); |
| } |
| |
| /** |
| * Sets the range, offset and extent of the scroll bar. The range represents the size of a |
| * container for the scrollbar thumb; offset is the distance from the start of the container to |
| * where the thumb should be; and finally, extent is the size of the thumb. |
| * |
| * <p>These values can be expressed in arbitrary units, so long as they share the same units. |
| * The values should also be positive. |
| * |
| * @param range The range of the scrollbar's thumb |
| * @param offset The offset of the scrollbar's thumb |
| * @param extent The extent of the scrollbar's thumb |
| */ |
| private void setParameters( |
| @IntRange(from = 0) int range, |
| @IntRange(from = 0) int offset, |
| @IntRange(from = 0) int extent) { |
| // Not laid out yet, so values cannot be calculated. |
| if (!mScrollView.isLaidOut()) { |
| return; |
| } |
| |
| // If the scroll bars aren't visible, then no need to update. |
| if (mScrollView.getVisibility() == View.GONE || range == 0) { |
| return; |
| } |
| |
| int thumbLength = calculateScrollThumbLength(range, extent); |
| int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength); |
| |
| // Sets the size of the thumb and request a redraw if needed. |
| ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams(); |
| |
| if (lp.height != thumbLength) { |
| lp.height = thumbLength; |
| mScrollThumb.requestLayout(); |
| } |
| |
| moveY(mScrollThumb, thumbOffset); |
| } |
| |
| /** |
| * Calculates and returns how big the scroll bar thumb should be based on the given range and |
| * extent. |
| * |
| * @param range The total amount of space the scroll bar is allowed to roam over. |
| * @param extent The amount of space that the scroll bar takes up relative to the range. |
| * @return The height of the scroll bar thumb in pixels. |
| */ |
| private int calculateScrollThumbLength(int range, int extent) { |
| // Scale the length by the available space that the thumb can fill. |
| return Math.max(Math.round(((float) extent / range) * mScrollTrack.getHeight()), |
| mScrollbarThumbMinHeight); |
| } |
| |
| /** |
| * Calculates and returns how much the scroll thumb should be offset from the top of where it |
| * has been laid out. |
| * |
| * @param range The total amount of space the scroll bar is allowed to roam over. |
| * @param offset The amount the scroll bar should be offset, expressed in the same units as |
| * the given range. |
| * @param thumbLength The current length of the thumb in pixels. |
| * @return The amount the thumb should be offset in pixels. |
| */ |
| private int calculateScrollThumbOffset(int range, int offset, int thumbLength) { |
| // Ensure that if the user has reached the bottom of the list, then the scroll bar is |
| // aligned to the bottom as well. Otherwise, scale the offset appropriately. |
| // This offset will be a value relative to the parent of this scrollbar, so start by where |
| // the top of scrollbar track is. |
| return mScrollTrack.getTop() |
| + (isDownEnabled() |
| ? Math.round(((float) offset / range) * (mScrollTrack.getHeight() - thumbLength)) |
| : mScrollTrack.getHeight() - thumbLength); |
| } |
| |
| /** |
| * Moves the given view to the specified 'y' position. |
| */ |
| private void moveY(final View view, float newPosition) { |
| view.animate() |
| .y(newPosition) |
| .setDuration(/* duration= */ 0) |
| .setInterpolator(mPaginationInterpolator) |
| .start(); |
| } |
| |
| private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener = |
| new RecyclerView.OnScrollListener() { |
| @Override |
| public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { |
| updatePaginationButtons(); |
| cacheChildrenHeight(recyclerView.getLayoutManager()); |
| } |
| }; |
| private final SparseArray<Integer> mChildHeightByAdapterPosition = new SparseArray(); |
| |
| private final RecyclerView.AdapterDataObserver mAdapterChangeObserver = |
| new RecyclerView.AdapterDataObserver() { |
| @Override |
| public void onChanged() { |
| clearCachedHeights(); |
| } |
| @Override |
| public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { |
| clearCachedHeights(); |
| } |
| @Override |
| public void onItemRangeChanged(int positionStart, int itemCount) { |
| clearCachedHeights(); |
| } |
| @Override |
| public void onItemRangeInserted(int positionStart, int itemCount) { |
| clearCachedHeights(); |
| } |
| @Override |
| public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { |
| clearCachedHeights(); |
| } |
| @Override |
| public void onItemRangeRemoved(int positionStart, int itemCount) { |
| clearCachedHeights(); |
| } |
| }; |
| |
| private void clearCachedHeights() { |
| mChildHeightByAdapterPosition.clear(); |
| cacheChildrenHeight(mRecyclerView.getLayoutManager()); |
| } |
| |
| private void cacheChildrenHeight(@Nullable RecyclerView.LayoutManager layoutManager) { |
| if (layoutManager == null) { |
| return; |
| } |
| for (int i = 0; i < layoutManager.getChildCount(); i++) { |
| View child = layoutManager.getChildAt(i); |
| int childPosition = layoutManager.getPosition(child); |
| if (mChildHeightByAdapterPosition.indexOfKey(childPosition) < 0) { |
| mChildHeightByAdapterPosition.put(childPosition, child.getHeight()); |
| } |
| } |
| } |
| |
| private int estimateNextPositionScrollUp(int currentPos, int scrollDistance, |
| OrientationHelper orientationHelper) { |
| int nextPos = 0; |
| int distance = 0; |
| for (int i = currentPos - 1; i >= 0; i--) { |
| if (mChildHeightByAdapterPosition.indexOfKey(i) < 0) { |
| // Use the average height estimate when there is not enough data |
| nextPos = mSnapHelper.estimateNextPositionDiffForScrollDistance(orientationHelper, |
| -scrollDistance); |
| break; |
| } |
| if ((distance + mChildHeightByAdapterPosition.get(i)) > Math.abs(scrollDistance)) { |
| nextPos = i - currentPos + 1; |
| break; |
| } |
| distance += mChildHeightByAdapterPosition.get(i); |
| } |
| return nextPos; |
| } |
| |
| private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) { |
| if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) { |
| // CarUiRecyclerView is assumed to be a list that always vertically scrolls. |
| mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager); |
| } |
| return mOrientationHelper; |
| } |
| |
| /** |
| * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the |
| * {@code CarUiRecyclerView}. |
| * |
| * <p>The resulting first item in the list will be snapped to so that it is completely visible. |
| * If this is not possible due to the first item being taller than the containing {@code |
| * CarUiRecyclerView}, then the snapping will not occur. |
| */ |
| void pageUp() { |
| int currentOffset = getRecyclerView().computeVerticalScrollOffset(); |
| RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); |
| if (layoutManager == null || layoutManager.getChildCount() == 0 || currentOffset == 0) { |
| return; |
| } |
| |
| // Use OrientationHelper to calculate scroll distance in order to match snapping behavior. |
| OrientationHelper orientationHelper = getOrientationHelper(layoutManager); |
| int screenSize = orientationHelper.getTotalSpace(); |
| int scrollDistance = screenSize; |
| |
| View currentPosView = getFirstMostVisibleChild(orientationHelper); |
| int currentPos = currentPosView != null ? mRecyclerView.getLayoutManager().getPosition( |
| currentPosView) : 0; |
| int nextPos = estimateNextPositionScrollUp(currentPos, |
| scrollDistance - Math.max(0, orientationHelper.getStartAfterPadding() |
| - orientationHelper.getDecoratedStart(currentPosView)), orientationHelper); |
| if (nextPos == 0) { |
| // Distance should always be positive. Negate its value to scroll up. |
| mRecyclerView.smoothScrollBy(0, -scrollDistance); |
| } else { |
| mRecyclerView.smoothScrollToPosition(Math.max(0, currentPos + nextPos)); |
| } |
| } |
| |
| private View getFirstMostVisibleChild(OrientationHelper helper) { |
| float mostVisiblePercent = 0; |
| View mostVisibleView = null; |
| |
| for (int i = 0; i < getRecyclerView().getLayoutManager().getChildCount(); i++) { |
| View child = getRecyclerView().getLayoutManager().getChildAt(i); |
| float visiblePercentage = CarUiSnapHelper.getPercentageVisible(child, helper); |
| if (visiblePercentage == 1f) { |
| mostVisibleView = child; |
| break; |
| } else if (visiblePercentage > mostVisiblePercent) { |
| mostVisiblePercent = visiblePercentage; |
| mostVisibleView = child; |
| } |
| } |
| |
| return mostVisibleView; |
| } |
| |
| /** |
| * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the |
| * {@code CarUiRecyclerView}. |
| * |
| * <p>This method will attempt to bring the last item in the list as the first item. If the |
| * current first item in the list is taller than the {@code CarUiRecyclerView}, then it will be |
| * scrolled the length of a page, but not snapped to. |
| */ |
| void pageDown() { |
| RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); |
| if (layoutManager == null || layoutManager.getChildCount() == 0) { |
| return; |
| } |
| |
| OrientationHelper orientationHelper = getOrientationHelper(layoutManager); |
| int screenSize = orientationHelper.getTotalSpace(); |
| int scrollDistance = screenSize; |
| |
| View currentPosView = getFirstMostVisibleChild(orientationHelper); |
| |
| // If current view is partially visible and bottom of the view is below visible area of |
| // the recyclerview either scroll down one page (screenSize) or enough to align the bottom |
| // of the view with the bottom of the recyclerview. Note that this will not cause a snap, |
| // because the current view is already snapped to the top or it wouldn't be the most |
| // visible view. |
| if (layoutManager.isViewPartiallyVisible(currentPosView, |
| /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false) |
| && orientationHelper.getDecoratedEnd(currentPosView) |
| > orientationHelper.getEndAfterPadding()) { |
| scrollDistance = Math.min(screenSize, |
| orientationHelper.getDecoratedEnd(currentPosView) |
| - orientationHelper.getEndAfterPadding()); |
| } |
| |
| // Iterate over the childview (bottom to top) and stop when we find the first |
| // view that we can snap to and the scroll size is less than max scroll size (screenSize) |
| for (int i = layoutManager.getChildCount() - 1; i >= 0; i--) { |
| View child = layoutManager.getChildAt(i); |
| |
| // Ignore the child if it's above the currentview, as scrolldown will only move down. |
| // Note that in case of gridview, child will not be the same as the currentview. |
| if (orientationHelper.getDecoratedStart(child) |
| <= orientationHelper.getDecoratedStart(currentPosView)) { |
| break; |
| } |
| |
| // Ignore the child if the scroll distance is bigger than the max scroll size |
| if (orientationHelper.getDecoratedStart(child) |
| - orientationHelper.getStartAfterPadding() <= screenSize) { |
| // If the child is already fully visible we can scroll even further. |
| if (orientationHelper.getDecoratedEnd(child) |
| <= orientationHelper.getEndAfterPadding()) { |
| scrollDistance = orientationHelper.getDecoratedEnd(child) |
| - orientationHelper.getStartAfterPadding(); |
| } else { |
| scrollDistance = orientationHelper.getDecoratedStart(child) |
| - orientationHelper.getStartAfterPadding(); |
| } |
| break; |
| } |
| } |
| |
| mRecyclerView.smoothScrollBy(0, scrollDistance); |
| } |
| |
| /** |
| * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is |
| * being called as a result of adapter changes, it should be called after the new layout has |
| * been calculated because the method of determining scrollbar visibility uses the current |
| * layout. If this is called after an adapter change but before the new layout, the visibility |
| * determination may not be correct. |
| */ |
| private void updatePaginationButtons() { |
| |
| boolean isAtStart = isAtStart(); |
| boolean isAtEnd = isAtEnd(); |
| RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); |
| |
| // enable/disable the button before the view is shown. So there is no flicker. |
| setUpEnabled(!isAtStart); |
| setDownEnabled(!isAtEnd); |
| |
| if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) { |
| mScrollView.setVisibility(View.INVISIBLE); |
| } else { |
| mScrollView.setVisibility(View.VISIBLE); |
| } |
| |
| if (layoutManager == null) { |
| return; |
| } |
| |
| if (layoutManager.canScrollVertically()) { |
| setParameters( |
| getRecyclerView().computeVerticalScrollRange(), |
| getRecyclerView().computeVerticalScrollOffset(), |
| getRecyclerView().computeVerticalScrollExtent()); |
| } else { |
| setParameters( |
| getRecyclerView().computeHorizontalScrollRange(), |
| getRecyclerView().computeHorizontalScrollOffset(), |
| getRecyclerView().computeHorizontalScrollExtent()); |
| } |
| |
| mScrollView.invalidate(); |
| } |
| |
| /** |
| * Returns {@code true} if the RecyclerView is completely displaying the first item. |
| */ |
| boolean isAtStart() { |
| return mSnapHelper.isAtStart(getRecyclerView().getLayoutManager()); |
| } |
| |
| /** |
| * Returns {@code true} if the RecyclerView is completely displaying the last item. |
| */ |
| boolean isAtEnd() { |
| return mSnapHelper.isAtEnd(getRecyclerView().getLayoutManager()); |
| } |
| } |